[FIX] runbot: fix markdown adding escape

A common error on runbot is to generate link containing a __init__.py
file

[/some/path/to/__init__.py](/some/path/to/__init__.py)

This would be rendered as

<a href="/some/path/to/<ins>init<ins>.py">/some/path/to/<ins>init<ins>.py</a>

Breaking the link, and the display of the name

By default markdown will not render links avoiding this issue, but it
will remain for the content of the a, needing to manage some kind of
escaping.

The way to escape markdown is to add a \ before any special character
This must be done upront before formating, adding the method
markdown_escape

Our implementation of markdown is not meant to meet the exact
specification of markdown but better suit our needs. One of the
requirements is to be able to use it to format message easily but adding
dynamic countent comming from the outside. One of the error than can
occur is also

'Some code `%s`' % code can also cause problem if code contains `

This issue could be solved using indented code block, but this would
need to complexify the generated string, have a dedicated method to
escape the code blocs, ...

Since we have the controll on the input, we can easily sanitize all
ynamic content to avoid such issues. For code block we introduce a way
to escape backtick (\`). It is non standard but will be easier to use.

Combine with that, the build._log method now allows to add args with the
values to format the string (similar to logging) but will escape
params by default. (cr.execute spirit)

name = '__init__.py'
url = 'path/to/__init__.py'
code = '# comment `for` something'
build._log('f', 'Some message [%s](%s) \n `%s`', name, url, code)

name, url and code will be escaped
This commit is contained in:
Xavier-Do 2024-09-03 15:25:17 +02:00 committed by Christophe Monniez
parent 2fd7f47e49
commit 21d6c84b26
12 changed files with 183 additions and 51 deletions

View File

@ -191,14 +191,17 @@ def pseudo_markdown(text):
codes = []
def code_remove(match):
codes.append(match.group(1))
return f'<code>{len(codes)-1}</code>'
return f'<code>{len(codes) - 1}</code>'
escape = r'(?<!\\)(?:(?:\\\\)*)'
text = re.sub(rf'{escape}`(.+?{escape})`', code_remove, text, flags=re.DOTALL)
patterns = {
r'`(.+?)`': code_remove,
r'\*\*(.+?)\*\*': '<strong>\\g<1></strong>',
r'~~(.+?)~~': '<del>\\g<1></del>', # it's not official markdown but who cares
r'__(.+?)__': '<ins>\\g<1></ins>', # same here, maybe we should change the method name
r'\r?\n': '<br/>',
r'\r?\n': '<br/>\n',
}
for p, b in patterns.items():
@ -209,15 +212,31 @@ def pseudo_markdown(text):
text = re_icon.sub('<i class="fa fa-\\g<1>"></i>', text)
# links
re_links = re.compile(r'\[(.+?)\]\((.+?)\)')
re_links = re.compile(rf'{escape}\[(.+?){escape}\]{escape}\(((http|/).+?{escape})\)')
text = re_links.sub('<a href="\\g<2>">\\g<1></a>', text)
def code_replace(match):
return f'<code>{codes[int(match.group(1))]}</code>'
text = Markup(re.sub(r'<code>(\d+)</code>', code_replace, text, flags=re.DOTALL))
text = markdown_unescape(text)
return text
patterns = ['\\', '[', ']', '(', ')', '_', '*', '#', '`']
def markdown_escape(text):
text = str(text)
for pat in patterns:
text = text.replace(pat, rf'\{pat}')
return text
def markdown_unescape(text):
for pat in patterns:
text = text.replace(rf'\{pat}', pat)
return text
def make_github_session(token):
session = requests.Session()

View File

@ -4,7 +4,7 @@ import datetime
import subprocess
from odoo import models, fields, api
from ..common import dt2time, s2human_long, pseudo_markdown
from ..common import dt2time, s2human_long, pseudo_markdown, markdown_escape
_logger = logging.getLogger(__name__)
@ -462,6 +462,7 @@ class Batch(models.Model):
self._log(message, *args, level='WARNING')
def _log(self, message, *args, level='INFO'):
args = tuple([markdown_escape(arg) for arg in args])
message = message % args if args else message
self.env['runbot.batch.log'].create({
'batch_id': self.id,

View File

@ -15,7 +15,7 @@ from pathlib import Path
from psycopg2 import sql
from psycopg2.extensions import TransactionRollbackError
from ..common import dt2time, now, grep, local_pgadmin_cursor, s2human, dest_reg, os, list_local_dbs, pseudo_markdown, RunbotException, findall, sanitize
from ..common import dt2time, now, grep, local_pgadmin_cursor, s2human, dest_reg, os, list_local_dbs, pseudo_markdown, RunbotException, findall, sanitize, markdown_escape
from ..container import docker_stop, docker_state, Command, docker_run
from ..fields import JsonDictField
@ -802,7 +802,7 @@ class BuildResult(models.Model):
return False
new_step = step_ids[next_index] # job to do, state is job_state (testing or running)
if new_step.domain_filter and not self.filtered_domain(safe_eval(new_step.domain_filter)):
self._log('run', '**Skipping** step ~~%s~~ from config **%s**' % (new_step.name, self.params_id.config_id.name), log_type='markdown', level='SEPARATOR')
self._log('run', '**Skipping** step ~~%s~~ from config **%s**', (new_step.name, self.params_id.config_id.name), log_type='markdown', level='SEPARATOR')
next_index += 1
continue
break
@ -852,7 +852,7 @@ class BuildResult(models.Model):
ro_volumes[f'/data/build/{dest}'] = source
if 'image_tag' not in kwargs:
kwargs.update({'image_tag': self.params_id.dockerfile_id.image_tag})
self._log('Preparing', 'Using Dockerfile Tag [%s](/runbot/dockerfile/tag/%s)' % (kwargs['image_tag'], kwargs['image_tag']), log_type='markdown')
self._log('Preparing', 'Using Dockerfile Tag [%s](/runbot/dockerfile/tag/%s)', kwargs['image_tag'], kwargs['image_tag'], log_type='markdown')
containers_memory_limit = self.env['ir.config_parameter'].sudo().get_param('runbot.runbot_containers_memory', 0)
if containers_memory_limit and 'memory' not in kwargs:
kwargs['memory'] = int(float(containers_memory_limit) * 1024 ** 3)
@ -973,13 +973,26 @@ class BuildResult(models.Model):
local_cr.execute(sql.SQL("""CREATE DATABASE {} TEMPLATE %s LC_COLLATE 'C' ENCODING 'unicode'""").format(sql.Identifier(dbname)), (db_template,))
self.env['runbot.database'].create({'name': dbname, 'build_id': self.id})
def _log(self, func, message, level='INFO', log_type='runbot', path='runbot'):
def _log(self, func, message, *args, level='INFO', log_type='runbot', path='runbot'):
def truncate(message, maxlenght=300000):
if len(message) > maxlenght:
return message[:maxlenght] + '[Truncate, message too long]'
return message
if len(message) > 300000:
message = message[:300000] + '[Truncate, message too long]'
if log_type == 'markdown':
args = tuple([markdown_escape(truncate(str(arg), maxlenght=200000)) for arg in args])
if args:
try:
message = message % args
except TypeError:
_logger.exception(f'Error while formating `{message}` with `{args}`')
message = ' ' .join([message] + args)
message = truncate(message)
self.ensure_one()
_logger.info("Build %s %s %s", self.id, func, message)
#_logger.info("Build %s %s %s", self.id, func, message)
return self.env['ir.logging'].create({
'build_id': self.id,
'level': level,

View File

@ -8,7 +8,7 @@ import re
import shlex
import time
from unidiff import PatchSet
from ..common import now, grep, time2str, rfind, s2human, os, RunbotException, ReProxy
from ..common import now, grep, time2str, rfind, s2human, os, RunbotException, ReProxy, markdown_escape
from ..container import docker_get_gateway_ip, Command
from odoo import models, fields, api
from odoo.exceptions import UserError, ValidationError
@ -332,6 +332,7 @@ class ConfigStep(models.Model):
'rfind': rfind,
'json_loads': json.loads,
'PatchSet': PatchSet,
'markdown_escape': markdown_escape,
}
def _run_python(self, build, force=False):
@ -523,7 +524,7 @@ class ConfigStep(models.Model):
valid_targets |= (builds_references_by_version_id.get(next_version.id) or build.browse())
for target in valid_targets:
build._log('', 'Checking upgrade to [%s](%s)' % (target.params_id.version_id.name, target.build_url), log_type='markdown')
build._log('', 'Checking upgrade to [%s](%s)', target.params_id.version_id.name, target.build_url, log_type='markdown')
for upgrade_db in upgrade_complement_step.upgrade_dbs:
if not upgrade_db.min_target_version_id or upgrade_db.min_target_version_id.number <= target.params_id.version_id.number:
# note: here we don't consider the upgrade_db config here
@ -660,7 +661,7 @@ class ConfigStep(models.Model):
# for commit_link in target.params_id.commit_link_ids:
# if commit_link.commit_id.repo_id not in repo_ids:
# additionnal_commit_links |= commit_link
# build._log('', 'Adding sources from build [%s](%s)' % (target.id, target.build_url), log_type='markdown')
# build._log('', 'Adding sources from build [%s](%s)', target.id, target.build_url, log_type='markdown')
child = build._add_child({
'upgrade_to_build_id': target.id,
@ -713,7 +714,7 @@ class ConfigStep(models.Model):
for commit in commit_ids:
if commit.repo_id not in target_repo_ids:
target_commit_ids |= commit
build._log('', 'Adding sources from build [%s](%s)' % (target.id, target.build_url), log_type='markdown')
build._log('', 'Adding sources from build [%s](%s)', target.id, target.build_url, log_type='markdown')
build = build.with_context(defined_commit_ids=target_commit_ids)
exports = build._checkout()
@ -772,7 +773,7 @@ class ConfigStep(models.Model):
download_db_name = '%s-%s' % (dump_build.dest, download_db_suffix)
zip_name = '%s.zip' % download_db_name
dump_url = '%s%s' % (dump_build._http_log_url(), zip_name)
build._log('test-migration', 'Restoring dump [%s](%s) from build [%s](%s)' % (zip_name, dump_url, dump_build.id, dump_build.build_url), log_type='markdown')
build._log('test-migration', 'Restoring dump [%s](%s) from build [%s](%s)', zip_name, dump_url, dump_build.id, dump_build.build_url, log_type='markdown')
restore_suffix = self.restore_rename_db_suffix or dump_db.db_suffix or suffix
assert restore_suffix
restore_db_name = '%s-%s' % (build.dest, restore_suffix)
@ -892,14 +893,14 @@ class ConfigStep(models.Model):
if self.coverage:
xml_url = '%scoverage.xml' % build._http_log_url()
html_url = 'http://%s/runbot/static/build/%s/coverage/index.html' % (build.host, build.dest)
message = 'Coverage report: [xml @icon-download](%s), [html @icon-eye](%s)' % (xml_url, html_url)
build._log('end_job', message, log_type='markdown')
message = 'Coverage report: [xml @icon-download](%s), [html @icon-eye](%s)'
build._log('end_job', message, xml_url, html_url, log_type='markdown')
if self.flamegraph:
dat_url = '%sflame_%s.%s' % (build._http_log_url(), self.name, 'log.gz')
svg_url = '%sflame_%s.%s' % (build._http_log_url(), self.name, 'svg')
message = 'Flamegraph report: [data @icon-download](%s), [svg @icon-eye](%s)' % (dat_url, svg_url)
build._log('end_job', message, log_type='markdown')
message = 'Flamegraph report: [data @icon-download](%s), [svg @icon-eye](%s)'
build._log('end_job', message, dat_url, svg_url, log_type='markdown')
def _modules_to_install(self, build):
return set(build._get_modules_to_test(modules_patterns=self.install_modules))
@ -1097,7 +1098,7 @@ class ConfigStep(models.Model):
build._log('make_stats', 'Getting stats from log file')
log_path = build._path('logs', '%s.txt' % self.name)
if not os.path.exists(log_path):
build._log('make_stats', 'Log **%s.txt** file not found' % self.name, level='INFO', log_type='markdown')
build._log('make_stats', 'Log **%s.txt** file not found', self.name, level='INFO', log_type='markdown')
return
try:
regex_ids = self.build_stat_regex_ids

View File

@ -1,5 +1,6 @@
import re
from odoo import models, fields
from ..common import markdown_escape
class ConfigStep(models.Model):
@ -18,7 +19,7 @@ class ConfigStep(models.Model):
build._log('', 'More than one open pr in this bundle for %s: %s' % (commit.repo_id.name, [pr.name for pr in repo_pr]), level='ERROR')
build.local_result = 'ko'
return {}
build._log('', 'PR [%s](%s) found for repo **%s**' % (repo_pr.dname, repo_pr.branch_url, commit.repo_id.name), log_type='markdown')
build._log('', 'PR [%s](%s) found for repo **%s**', repo_pr.dname, repo_pr.branch_url, commit.repo_id.name, log_type='markdown')
pr_by_commit[commit_link] = repo_pr
else:
build._log('', 'No pr for repo %s, skipping' % commit.repo_id.name)
@ -124,10 +125,10 @@ class ConfigStep(models.Model):
for file, file_reviewers in reviewer_per_file.items():
href = 'https://%s/blob/%s/%s' % (commit_link.branch_id.remote_id.base_url, commit_link.commit_id.name, file.split('/', 1)[-1])
if file_reviewers:
build._log('', 'Adding %s to reviewers for file [%s](%s)' % (', '.join(sorted(file_reviewers)), file, href), log_type='markdown')
build._log('', 'Adding %s to reviewers for file [%s](%s)', ', '.join(sorted(file_reviewers)), file, href, log_type='markdown')
reviewers |= file_reviewers
else:
build._log('', 'No reviewer for file [%s](%s)' % (file, href), log_type='markdown')
build._log('', 'No reviewer for file [%s](%s)', file, href, log_type='markdown')
if reviewers:
pr = pr_by_commit[commit_link]
@ -137,19 +138,19 @@ class ConfigStep(models.Model):
author_skipped_teams = set(author_skippable_teams.mapped('github_team'))
if author_skipped_teams:
new_reviewers = new_reviewers - author_skipped_teams
build._log('', 'Skipping teams %s since author is part of the team members' % (sorted(author_skipped_teams),), log_type='markdown')
build._log('', 'Skipping teams %s since author is part of the team members', sorted(author_skipped_teams), log_type='markdown')
fw_skippable_teams = skippable_teams.filtered(lambda team: team.skip_fw_pr and team.github_team in new_reviewers and pr.pr_author == fw_bot)
fw_skipped_teams = set(fw_skippable_teams.mapped('github_team'))
if fw_skipped_teams:
new_reviewers = new_reviewers - fw_skipped_teams
build._log('', 'Skipping teams %s (ignore forwardport)' % (sorted(fw_skipped_teams),), log_type='markdown')
build._log('', 'Skipping teams %s (ignore forwardport)', sorted(fw_skipped_teams), log_type='markdown')
new_reviewers = sorted(new_reviewers)
build._log('', 'Requesting review for pull request [%s](%s): %s' % (pr.dname, pr.branch_url, ', '.join(new_reviewers)), log_type='markdown')
build._log('', 'Requesting review for pull request [%s](%s): %s', pr.dname, pr.branch_url, ', '.join(new_reviewers), log_type='markdown')
response = pr.remote_id._github('/repos/:owner/:repo/pulls/%s/requested_reviewers' % pr.name, {"team_reviewers": list(new_reviewers)}, ignore_errors=False)
pr._update_branch_infos(response)
pr['reviewers'] = ','.join(sorted(reviewers))
else:
build._log('', 'All reviewers are already on pull request [%s](%s)' % (pr.dname, pr.branch_url,), log_type='markdown')
build._log('', 'All reviewers are already on pull request [%s](%s)', pr.dname, pr.branch_url, log_type='markdown')

View File

@ -7,6 +7,7 @@ from collections import defaultdict
from ..common import pseudo_markdown
from odoo import models, fields, tools, api
from odoo.exceptions import UserError
from odoo.tools import html_escape
_logger = logging.getLogger(__name__)
@ -47,6 +48,9 @@ class IrLogging(models.Model):
""" Apply pseudo markdown parser for message.
"""
self.ensure_one()
if self.type != 'markdown':
_logger.warning('Calling _markdown on a non markdown log')
return html_escape(self.message)
return pseudo_markdown(self.message)
def _compute_known_error(self):

View File

@ -103,7 +103,7 @@
<div class="batch_slots">
<t t-foreach="batch.slot_ids" t-as="slot">
<t t-if="slot.build_id">
<div t-if="((not slot.trigger_id.hide and trigger_display is None) or (trigger_display and slot.trigger_id.id in trigger_display)) or slot.build_id.global_result == 'ko'"
<div t-if="((not slot.trigger_id.hide and trigger_display is None) or (trigger_display and slot.trigger_id.id in trigger_display)) or slot.build_id.global_result != 'ok'"
t-call="runbot.slot_button" class="slot_container"/>
</t>
</t>

View File

@ -6,6 +6,7 @@ from odoo.tools import mute_logger
from odoo.exceptions import UserError
from odoo.addons.runbot.common import RunbotException
from .common import RunbotCase
from ..common import markdown_unescape
class TestBuildConfigStepCommon(RunbotCase):
def setUp(self):
@ -119,10 +120,10 @@ class TestCodeowner(TestBuildConfigStepCommon):
self.config_step._run_codeowner(self.parent_build)
messages = self.parent_build.log_ids.mapped('message')
self.assertEqual(messages[1], 'Checking 2 codeowner regexed on 3 files')
self.assertEqual(messages[2], 'Adding team_js to reviewers for file [server/file.js](https://False/blob/dfdfcfcf/file.js)')
self.assertEqual(messages[3], 'Adding team_py to reviewers for file [server/file.py](https://False/blob/dfdfcfcf/file.py)')
self.assertEqual(messages[4], 'Adding codeowner-team to reviewers for file [server/file.xml](https://False/blob/dfdfcfcf/file.xml)')
self.assertEqual(messages[5], 'Requesting review for pull request [base/server:1234](https://example.com/base/server/pull/1234): codeowner-team, team_js, team_py')
self.assertEqual(markdown_unescape(messages[2]), 'Adding team_js to reviewers for file [server/file.js](https://False/blob/dfdfcfcf/file.js)')
self.assertEqual(markdown_unescape(messages[3]), 'Adding team_py to reviewers for file [server/file.py](https://False/blob/dfdfcfcf/file.py)')
self.assertEqual(markdown_unescape(messages[4]), 'Adding codeowner-team to reviewers for file [server/file.xml](https://False/blob/dfdfcfcf/file.xml)')
self.assertEqual(markdown_unescape(messages[5]), 'Requesting review for pull request [base/server:1234](https://example.com/base/server/pull/1234): codeowner-team, team_js, team_py')
self.assertEqual(self.dev_pr.reviewers, 'codeowner-team,team_js,team_py')
def test_codeowner_regex_some_already_on(self):
@ -130,7 +131,7 @@ class TestCodeowner(TestBuildConfigStepCommon):
self.dev_pr.reviewers = 'codeowner-team,team_js'
self.config_step._run_codeowner(self.parent_build)
messages = self.parent_build.log_ids.mapped('message')
self.assertEqual(messages[5], 'Requesting review for pull request [base/server:1234](https://example.com/base/server/pull/1234): team_py')
self.assertEqual(markdown_unescape(messages[5]), 'Requesting review for pull request [base/server:1234](https://example.com/base/server/pull/1234): team_py')
def test_codeowner_regex_all_already_on(self):
self.diff = 'file.js\nfile.py\nfile.xml'
@ -147,8 +148,8 @@ class TestCodeowner(TestBuildConfigStepCommon):
self.dev_pr.pr_author = 'some_member'
self.config_step._run_codeowner(self.parent_build)
messages = self.parent_build.log_ids.mapped('message')
self.assertEqual(messages[5], "Skipping teams ['team_py'] since author is part of the team members")
self.assertEqual(messages[6], 'Requesting review for pull request [base/server:1234](https://example.com/base/server/pull/1234): codeowner-team, team_js')
self.assertEqual(markdown_unescape(messages[5]), "Skipping teams ['team_py'] since author is part of the team members")
self.assertEqual(markdown_unescape(messages[6]), 'Requesting review for pull request [base/server:1234](https://example.com/base/server/pull/1234): codeowner-team, team_js')
self.assertEqual(self.dev_pr.reviewers, 'codeowner-team,team_js,team_py')
def test_codeowner_ownership_base(self):
@ -160,7 +161,7 @@ class TestCodeowner(TestBuildConfigStepCommon):
self.config_step._run_codeowner(self.parent_build)
messages = self.parent_build.log_ids.mapped('message')
self.assertEqual(
messages[2],
markdown_unescape(messages[2]),
'Adding team_01, team_py to reviewers for file [server/core/addons/module1/some/file.py](https://False/blob/dfdfcfcf/core/addons/module1/some/file.py)'
)
@ -173,7 +174,7 @@ class TestCodeowner(TestBuildConfigStepCommon):
self.config_step._run_codeowner(self.parent_build)
messages = self.parent_build.log_ids.mapped('message')
self.assertEqual(
messages[2],
markdown_unescape(messages[2]),
'Adding team_py to reviewers for file [server/core/addons/module1/some/file.py](https://False/blob/dfdfcfcf/core/addons/module1/some/file.py)'
)
@ -189,7 +190,7 @@ class TestCodeowner(TestBuildConfigStepCommon):
'core/addons/module4/some/file.txt',
])
self.config_step._run_codeowner(self.parent_build)
messages = self.parent_build.log_ids.mapped('message')
messages = [markdown_unescape(message) for message in self.parent_build.log_ids.mapped('message')]
self.assertEqual(messages, [
'PR [base/server:1234](https://example.com/base/server/pull/1234) found for repo **server**',
'Checking 2 codeowner regexed on 4 files',
@ -200,6 +201,21 @@ class TestCodeowner(TestBuildConfigStepCommon):
'Requesting review for pull request [base/server:1234](https://example.com/base/server/pull/1234): codeowner-team, team_01, team_02, team_js, team_py'
])
def test_codeowner___init__log(self):
module1 = self.env['runbot.module'].create({'name': "module1"})
self.env['runbot.module.ownership'].create({'team_id': self.team1.id, 'module_id': module1.id})
self.diff = '\n'.join([
'core/addons/module1/some/__init__.py',
])
self.config_step._run_codeowner(self.parent_build)
logs = self.parent_build.log_ids
print
self.assertEqual(
logs[2]._markdown(),
'Adding team_01, team_py to reviewers for file <a href="https://False/blob/dfdfcfcf/core/addons/module1/some/__init__.py">server/core/addons/module1/some/__init__.py</a>',
'__init__.py should not be replaced by <ins>init</ins>.py'
)
class TestBuildConfigStepRestore(TestBuildConfigStepCommon):
@classmethod

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from ..common import markdown_escape, markdown_unescape
from .common import RunbotCase
@ -7,7 +8,7 @@ class TestIrLogging(RunbotCase):
def test_markdown(self):
log = self.env['ir.logging'].create({
'name': 'odoo.runbot',
'type': 'runbot',
'type': 'markdown',
'path': 'runbot',
'level': 'INFO',
'line': 0,
@ -26,10 +27,16 @@ class TestIrLogging(RunbotCase):
# 'a bit of code <code>import foo\nfoo.bar</code>'
#)
log.message = '`import foo`'
self.assertEqual(
str(log._markdown()),
'<code>import foo</code>',
)
log.message = 'a bit of code :\n`import foo`'
self.assertEqual(
log._markdown(),
'a bit of code :<br/><code>import foo</code>'
str(log._markdown()),
'a bit of code :<br/>\n<code>import foo</code>',
)
@ -43,20 +50,20 @@ class TestIrLogging(RunbotCase):
log.message = 'a bit of code :\n`print(__name__)`'
self.assertEqual(
log._markdown(),
'a bit of code :<br/><code>print(__name__)</code>'
'a bit of code :<br/>\n<code>print(__name__)</code>'
)
log.message = 'a bit of __code__ :\n`print(__name__)` **but also** `print(__name__)`'
self.assertEqual(
log._markdown(),
'a bit of <ins>code</ins> :<br/><code>print(__name__)</code> <strong>but also</strong> <code>print(__name__)</code>'
'a bit of <ins>code</ins> :<br/>\n<code>print(__name__)</code> <strong>but also</strong> <code>print(__name__)</code>'
)
# test links
log.message = 'This [link](https://wwww.somewhere.com) goes to somewhere and [this one](http://www.nowhere.com) to nowhere.'
self.assertEqual(
log._markdown(),
str(log._markdown()),
'This <a href="https://wwww.somewhere.com">link</a> goes to somewhere and <a href="http://www.nowhere.com">this one</a> to nowhere.'
)
@ -80,3 +87,73 @@ class TestIrLogging(RunbotCase):
log._markdown(),
'foo &lt;script&gt;console.log(&#34;hello world&#34;)&lt;/script&gt;'
)
log.message = f'[file]({markdown_escape("https://repo/file/__init__.py")})'
self.assertEqual(
str(log._markdown()),
'<a href="https://repo/file/__init__.py">file</a>',
)
# BEHAVIOUR TO DEFINE
log.message = f'[__underline text__]({markdown_escape("https://repo/file/__init__.py")})'
self.assertEqual(
str(log._markdown()),
'<a href="https://repo/file/__init__.py"><ins>underline text</ins></a>',
)
# BEHAVIOUR TO DEFINE
log.message = f'[{markdown_escape("__init__.py")}]({markdown_escape("https://repo/file/__init__.py")})'
self.assertEqual(
str(log._markdown()),
'<a href="https://repo/file/__init__.py">__init__.py</a>',
)
log.message = f'''This is a list of failures in some files:
[{markdown_escape("__init__.py")}]({markdown_escape("https://repo/file/__init__.py")})
`{markdown_escape("Some code with talking about __enter__")}`
[{markdown_escape("__init__.py")}]({markdown_escape("https://repo/file/__init__.py")})
`{markdown_escape("Some code with `code block` inside")}`'''
self.assertEqual(
str(log._markdown()),
'''This is a list of failures in some files:<br/>
<a href="https://repo/file/__init__.py">__init__.py</a><br/>
<code>Some code with talking about __enter__</code><br/>
<a href="https://repo/file/__init__.py">__init__.py</a><br/>
<code>Some code with `code block` inside</code>''')
for code in [
'leading\\',
'leading\\\\',
'`',
'\\`',
'\\``',
'``',
'`\n`',
]:
escaped_code = markdown_escape(code)
log.message = f'This is a bloc code `{escaped_code}`'
self.assertEqual(
str(log._markdown()),
f'This is a bloc code <code>{code}</code>',
)
def test_build_log_markdown(self):
build = self.env['runbot.build'].create({'params_id': self.base_params.id})
def last_log():
return str(build.log_ids[-1]._markdown())
name = '__init__.py'
url = '/path/to/__init__.py'
code = '# comment `for` something'
build._log('f', 'Some message [%s](%s) \n `%s`', name, url, code, log_type='markdown')
self.assertEqual(last_log(), f'Some message <a href="{url}">{name}</a> <br/>\n <code>{code}</code>')
name = '[__init__.py]'
url = '/path/to/__init__.py=(test))'
code = '# comment `for` something\\'
build._log('f', 'Some message [%s](%s) \n `%s`', name, url, code, log_type='markdown')
self.assertEqual(last_log(), f'Some message <a href="{url}">{name}</a> <br/>\n <code>{code}</code>')

View File

@ -26,7 +26,7 @@ class Step(models.Model):
if email in checked:
continue
checked.add(email)
build._log('check_cla', "[Odoo CLA signature](https://www.odoo.com/sign-cla) check for %s (%s) " % (commit.author, email), log_type='markdown')
build._log('check_cla', "[Odoo CLA signature](https://www.odoo.com/sign-cla) check for %s (%s) ", commit.author, email, log_type='markdown')
mo = re.search('[^ <@]+@[^ @>]+', email or '')
if mo:
email = mo.group(0).lower()

View File

@ -133,7 +133,7 @@ class Runbot(models.AbstractModel):
build._log('******','Starting step Y', level='SEPARATOR')
build._log('******','Some log', level='ERROR')
build._log('******','Some log\n with multiple lines', level='ERROR')
build._log('******','**Some** *markdown* [log](http://example.com)', log_type='markdown')
build._log('******','**Some** *markdown* [log](%s)', 'http://example.com', log_type='markdown')
build._log('******','Step x finished', level='SEPARATOR')
build.local_state = 'done'