diff --git a/conftest.py b/conftest.py index 7163552f..38794fb4 100644 --- a/conftest.py +++ b/conftest.py @@ -810,6 +810,11 @@ class Repo: r = self._session.patch('https://api.github.com/repos/{}/git/refs/{}'.format(self.name, name), json={'sha': commit, 'force': force}) assert r.ok, r.text + def delete_ref(self, name): + assert self.hook + r = self._session.delete(f'https://api.github.com/repos/{self.name}/git/refs/{name}') + assert r.ok, r.text + def protect(self, branch): assert self.hook r = self._session.put('https://api.github.com/repos/{}/branches/{}/protection'.format(self.name, branch), json={ @@ -1244,13 +1249,27 @@ class LabelsProxy(collections.abc.MutableSet): class Environment: def __init__(self, port, db): - self._uid = xmlrpc.client.ServerProxy('http://localhost:{}/xmlrpc/2/common'.format(port)).authenticate(db, 'admin', 'admin', {}) - self._object = xmlrpc.client.ServerProxy('http://localhost:{}/xmlrpc/2/object'.format(port)) + self._port = port self._db = db + self._uid = None + self._password = None + self._object = xmlrpc.client.ServerProxy(f'http://localhost:{port}/xmlrpc/2/object') + self.login('admin', 'admin') + + def with_user(self, login, password): + env = copy.copy(self) + env.login(login, password) + return env + + def login(self, login, password): + self._password = password + self._uid = xmlrpc.client.ServerProxy( + f'http://localhost:{self._port}/xmlrpc/2/common' + ).authenticate(self._db, login, password, {}) def __call__(self, model, method, *args, **kwargs): return self._object.execute_kw( - self._db, self._uid, 'admin', + self._db, self._uid, self._password, model, method, args, kwargs ) diff --git a/runbot_merge/git.py b/runbot_merge/git.py index d9937329..8f2732a8 100644 --- a/runbot_merge/git.py +++ b/runbot_merge/git.py @@ -7,7 +7,8 @@ import resource import stat import subprocess from operator import methodcaller -from typing import Optional, TypeVar, Union, Sequence, Tuple, Dict, Iterable +from typing import Optional, TypeVar, Union, Sequence, Tuple, Dict +from collections.abc import Iterable, Mapping, Callable from odoo.tools.appdirs import user_cache_dir from .github import MergeError, PrCommit @@ -76,6 +77,7 @@ class Repo: config.setdefault('stderr', subprocess.PIPE) self._config = config self._params = () + self.runner = subprocess.run def __getattr__(self, name: str) -> 'GitCommand': return GitCommand(self, name.replace('_', '-')) @@ -86,7 +88,7 @@ class Repo: + tuple(itertools.chain.from_iterable(('-c', p) for p in self._params + ALWAYS))\ + args try: - return subprocess.run(args, preexec_fn=_bypass_limits, **opts) + return self.runner(args, preexec_fn=_bypass_limits, **opts) except subprocess.CalledProcessError as e: stream = e.stderr or e.stdout if stream: @@ -246,30 +248,14 @@ class Repo: *itertools.chain.from_iterable(('-p', p) for p in parents), ) - - def modify_delete(self, tree: str, files: Iterable[str]) -> str: - """Updates ``files`` in ``tree`` to add conflict markers to show them - as being modify/delete-ed, rather than have only the original content. - - This is because having just content in a valid file is easy to miss, - causing file resurrections as they get committed rather than re-removed. - - TODO: maybe extract the diff information compared to before they were removed? idk - """ + def update_tree(self, tree: str, files: Mapping[str, Callable[[Self, str], str]]) -> str: # FIXME: either ignore or process binary files somehow (how does git show conflicts in binary files?) repo = self.stdout().with_config(stderr=None, text=True, check=False, encoding="utf-8") - for f in files: - contents = repo.cat_file("-p", f"{tree}:{f}").stdout - # decorate the contents as if HEAD and BASE are empty - oid = repo\ - .with_config(input=f"""\ -<<<\x3c<<< HEAD -||||||| MERGE BASE -======= -{contents} ->>>\x3e>>> FORWARD PORTED -""")\ - .hash_object("-w", "--stdin", "--path", f)\ + for f, c in files.items(): + new_contents = c(repo, f) + oid = repo \ + .with_config(input=new_contents) \ + .hash_object("-w", "--stdin", "--path", f) \ .stdout.strip() # we need to rewrite every tree from the parent of `f` @@ -286,6 +272,26 @@ class Repo: tree = oid return tree + def modify_delete(self, tree: str, files: Iterable[str]) -> str: + """Updates ``files`` in ``tree`` to add conflict markers to show them + as being modify/delete-ed, rather than have only the original content. + + This is because having just content in a valid file is easy to miss, + causing file resurrections as they get committed rather than re-removed. + + TODO: maybe extract the diff information compared to before they were removed? idk + """ + def rewriter(r: Self, f: str) -> str: + contents = r.cat_file("-p", f"{tree}:{f}").stdout + return f"""\ +<<<\x3c<<< HEAD +||||||| MERGE BASE +======= +{contents} +>>>\x3e>>> FORWARD PORTED +""" + return self.update_tree(tree, dict.fromkeys(files, rewriter)) + def check(p: subprocess.CompletedProcess) -> subprocess.CompletedProcess: if not p.returncode: diff --git a/runbot_merge/models/__init__.py b/runbot_merge/models/__init__.py index c1cee75c..4a25ecd4 100644 --- a/runbot_merge/models/__init__.py +++ b/runbot_merge/models/__init__.py @@ -4,6 +4,7 @@ from . import res_partner from . import project from . import pull_requests from . import batch +from . import patcher from . import project_freeze from . import stagings_create from . import staging_cancel diff --git a/runbot_merge/models/patcher.py b/runbot_merge/models/patcher.py new file mode 100644 index 00000000..f007db0f --- /dev/null +++ b/runbot_merge/models/patcher.py @@ -0,0 +1,311 @@ +""" Implements direct (unstaged) patching. + +Useful for massive data changes which are a pain to merge normally but very +unlikely to break things (e.g. i18n), fixes so urgent staging is an unacceptable +overhead, or FBI backdoors oh wait forget about that last one. +""" +from __future__ import annotations + +import logging +import pathlib +import re +import subprocess +import tarfile +import tempfile +from dataclasses import dataclass +from email import message_from_string, policy +from email.utils import parseaddr +from typing import Union + +from odoo import models, fields, api +from odoo.exceptions import ValidationError +from odoo.tools.mail import plaintext2html + +from .pull_requests import Branch +from .. import git + +_logger = logging.getLogger(__name__) +FILE_PATTERN = re.compile(r""" +# paths with spaces don't work well as the path can be followed by a timestamp +# (in an unspecified format?) +---\x20a/(?P\S+)(:?\s.*)?\n +\+\+\+\x20b/(?P\S+)(:?\s.*)?\n +@@\x20-(\d+(,\d+)?)\x20\+(\d+(,\d+)?)\x20@@ # trailing garbage +""", re.VERBOSE) + + +Authorship = Union[None, tuple[str, str], tuple[str, str, str]] +@dataclass +class ParseResult: + kind: str + author: Authorship + committer: Authorship + message: str + patch: str + + +def expect(line: str, starts_with: str, message: str) -> str: + if not line.startswith(starts_with): + raise ValidationError(message) + return line + + +def parse_show(p: Patch) -> ParseResult: + # headers are Author, Date or Author, AuthorDate, Commit, CommitDate + # commit message is indented 4 spaces + lines = iter(p.patch.splitlines(keepends=True)) + if not next(lines).startswith("commit "): + raise ValidationError("Invalid patch") + name, email = parseaddr( + expect(next(lines), "Author:", "Missing author") + .split(maxsplit=1)[1]) + date: str = next(lines) + header, date = date.split(maxsplit=1) + author = (name, email, date) + if header.startswith("Date:"): + committer = author + elif header.startswith("AuthorDate:"): + commit = expect(next(lines), "Commit:", "Missing committer") + commit_date = expect(next(lines), "CommitDate:", "Missing commit date") + name, email = parseaddr(commit.split(maxsplit=1)[1]) + committer = (name, email, commit_date.split(maxsplit=1)[1]) + else: + raise ValidationError( + "Invalid patch: expected 'Date:' or 'AuthorDate:' pseudo-header, " + f"found {header}.\nOnly 'medium' and 'fuller' formats are supported") + + # skip possible extra headers before the message + while next(lines) != ' \n': + continue + + body = [] + while (l := next(lines)) != ' \n': + body.append(l.removeprefix(' ')) + + # remainder should be the patch + patch = "".join( + line for line in lines + if not line.startswith("git --diff ") + if not line.startswith("index ") + ) + return ParseResult(kind="show", author=author, committer=committer, message="".join(body).rstrip(), patch=patch) + + +def parse_format_patch(p: Patch) -> ParseResult: + m = message_from_string(p.patch, policy=policy.default) + if m.is_multipart(): + raise ValidationError("multipart patches are not supported.") + + name, email = parseaddr(m['from']) + author = (name, email, m['date']) + msg = m['subject'].removeprefix('[PATCH] ') + body, _, rest = m.get_content().partition('---\n') + if body: + msg += '\n\n' + body + + # split off the signature, per RFC 3676 ยง 4.3. + # leave the diffstat in as it *should* not confuse tooling? + patch, _, _ = rest.rpartition("-- \n") + # git (diff, show, format-patch) adds command and index headers to every + # file header, which patch(1) chokes on, strip them... but maybe this should + # extract the udiff sections instead? + patch = re.sub( + "^(git --diff .*|index .*)\n", + "", + patch, + flags=re.MULTILINE, + ) + return ParseResult(kind="format-patch", author=author, committer=author, message=msg, patch=patch) + + +class PatchFailure(Exception): + pass + + +class Patch(models.Model): + _name = "runbot_merge.patch" + _inherit = ['mail.thread'] + _description = "Unstaged direct-application patch" + + active = fields.Boolean(default=True, tracking=True) + repository = fields.Many2one('runbot_merge.repository', required=True, tracking=True) + target = fields.Many2one('runbot_merge.branch', required=True, tracking=True) + commit = fields.Char(size=40, string="commit to cherry-pick, must be in-network", tracking=True) + + patch = fields.Text(string="unified diff to apply", tracking=True) + format = fields.Selection([ + ("format-patch", "format-patch"), + ("show", "show"), + ], compute="_compute_patch_meta") + message = fields.Text(compute="_compute_patch_meta") + + _sql_constraints = [ + ('patch_contents_either', 'check ((commit is null) != (patch is null))', 'Either the commit or patch must be set, and not both.'), + ] + + @api.depends("patch") + def _compute_patch_meta(self) -> None: + for p in self: + if r := p._parse_patch(): + p.format = r.kind + p.message = r.message + else: + p.format = False + p.message = False + + def _parse_patch(self) -> ParseResult | None: + if not self.patch: + return None + + if self.patch.startswith("commit "): + return parse_show(self) + elif self.patch.startswith("From "): + return parse_format_patch(self) + else: + raise ValidationError("Only `git show` and `git format-patch` formats are supported") + + def _auto_init(self): + super()._auto_init() + self.env.cr.execute(""" + CREATE INDEX IF NOT EXISTS runbot_merge_patch_active + ON runbot_merge_patch (target) WHERE active + """) + + @api.model_create_multi + def create(self, vals_list): + if any(vals.get('active') is not False for vals in vals_list): + self.env.ref("runbot_merge.staging_cron")._trigger() + return super().create(vals_list) + + def write(self, vals): + if vals.get("active") is not False: + self.env.ref("runbot_merge.staging_cron")._trigger() + return super().write(vals) + + @api.constrains('patch') + def _validate_patch(self): + for p in self: + patch = p._parse_patch() + if not patch: + continue + + has_files = False + for m in FILE_PATTERN.finditer(patch.patch): + has_files = True + if m['file_from'] != m['file_to']: + raise ValidationError("Only patches updating a file in place are supported, not creation, removal, or renaming.") + if not has_files: + raise ValidationError("Patches should have files they patch, found none.") + + def _apply_patches(self, target: Branch) -> bool: + patches = self.search([('target', '=', target.id)], order='id asc') + if not patches: + return True + + commits = {} + repos = {} + for p in patches: + repos[p.repository] = git.get_local(p.repository).check(True) + commits.setdefault(p.repository, set()) + if p.commit: + commits[p.repository].add(p.commit) + + for patch in patches: + patch.active = False + r = repos[patch.repository] + remote = git.source_url(patch.repository) + if (cs := commits.pop(patch.repository, None)) is not None: + # first time encountering a repo, fetch the branch and commits + r.fetch(remote, f"+refs/heads/{target.name}:refs/heads/{target.name}", *cs, no_tags=True) + + _logger.info( + "Applying %s to %r (in %s)", + patch, + patch.target.display_name, + patch.repository.name, + ) + try: + c = patch._apply_commit(r) if patch.commit else patch._apply_patch(r) + except Exception as e: + if isinstance(e, PatchFailure): + subject = "Unable to apply patch" + else: + subject = "Unknown error while trying to apply patch" + patch.message_post(body=plaintext2html(e), subject=subject) + continue + # `.` is the local "remote", so this updates target to c + r.fetch(".", f"{c}:{target.name}") + + # push patch by patch, avoids sync issues and in most cases we have 0~1 patches + res = r.check(False).stdout()\ + .with_config(encoding="utf-8")\ + .push(remote, f"{target.name}:{target.name}") + ## one of the repos is out of consistency, loop around to new staging? + if res.returncode: + _logger.warning( + "Unable to push result of %s\nout:\n%s\nerr:\n%s", + patch.id, + res.stdout, + res.stderr, + ) + return False + + return True + + def _apply_commit(self, r: git.Repo) -> str: + r = r.check(True).stdout().with_config(encoding="utf-8") + # TODO: maybe use git-rev-list instead? + sha = r.show('--no-patch', '--pretty=%H', self.target.name).stdout.strip() + target = r.show('--no-patch', '--pretty=%an%n%ae%n%ai%n%cn%n%ce%n%ci%n%B', self.commit) + # retrieve metadata of cherrypicked commit + author_name, author_email, author_date, committer_name, committer_email, committer_date, body =\ + target.stdout.strip().split("\n", 6) + + res = r.check(False).merge_tree(sha, self.commit) + if res.returncode: + _conflict_info, _, informational = res.stdout.partition('\n\n') + raise PatchFailure(informational) + + return r.commit_tree( + tree=res.stdout.strip(), + parents=[sha], + message=body.strip(), + author=(author_name, author_email, author_date), + committer=(committer_name, committer_email, committer_date), + ).stdout.strip() + + def _apply_patch(self, r: git.Repo) -> str: + p = self._parse_patch() + files = dict.fromkeys( + (m['file_to'] for m in FILE_PATTERN.finditer(p.patch)), + lambda _r, f: pathlib.Path(tmpdir, f).read_text(encoding="utf-8"), + ) + archiver = r.stdout(True) + # if the parent is checked then we can't get rid of the kwarg and popen doesn't support it + archiver._config.pop('check', None) + archiver.runner = subprocess.Popen + with archiver.archive(self.target.name, *files) as out, \ + tarfile.open(fileobj=out.stdout, mode='r|') as tf,\ + tempfile.TemporaryDirectory() as tmpdir: + tf.extractall(tmpdir) + patch = subprocess.run( + ['patch', '-p1', '-d', tmpdir], + input=p.patch, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding='utf-8', + ) + if patch.returncode: + raise PatchFailure(patch.stdout) + new_tree = r.update_tree(self.target.name, files) + + sha = r.stdout().with_config(encoding='utf-8')\ + .show('--no-patch', '--pretty=%H', self.target.name)\ + .stdout.strip() + return r.commit_tree( + tree=new_tree, + parents=[sha], + message=p.message, + author=p.author, + committer=p.committer, + ).stdout.strip() diff --git a/runbot_merge/models/project.py b/runbot_merge/models/project.py index 55210ebb..629a4f58 100644 --- a/runbot_merge/models/project.py +++ b/runbot_merge/models/project.py @@ -147,6 +147,18 @@ class Project(models.Model): ('staging_enabled', '=', True), ('project_id.staging_enabled', '=', True), ]): + try: + with self.env.cr.savepoint(): + if not self.env['runbot_merge.patch']._apply_patches(branch): + self.env.ref("runbot_merge.staging_cron")._trigger() + return + + except Exception: + _logger.exception("Failed to apply patches to branch %r", branch.name) + else: + if commit: + self.env.cr.commit() + try: with self.env.cr.savepoint(), \ sentry_sdk.start_span(description=f'create staging {branch.name}') as span: diff --git a/runbot_merge/security/ir.model.access.csv b/runbot_merge/security/ir.model.access.csv index 76bbf09d..0e8b0e8d 100644 --- a/runbot_merge/security/ir.model.access.csv +++ b/runbot_merge/security/ir.model.access.csv @@ -30,5 +30,5 @@ access_runbot_merge_pull_requests,User access to PR,model_runbot_merge_pull_requ access_runbot_merge_pull_requests_feedback,Users have no reason to access feedback,model_runbot_merge_pull_requests_feedback,,0,0,0,0 access_runbot_merge_review_rights_2,Users can see partners,model_res_partner_review,base.group_user,1,0,0,0 access_runbot_merge_review_override_2,Users can see partners,model_res_partner_override,base.group_user,1,0,0,0 -runbot_merge.access_runbot_merge_pull_requests_feedback_template,access_runbot_merge_pull_requests_feedback_template,runbot_merge.model_runbot_merge_pull_requests_feedback_template,base.group_system,1,1,0,0 - +access_runbot_merge_pull_requests_feedback_template,access_runbot_merge_pull_requests_feedback_template,runbot_merge.model_runbot_merge_pull_requests_feedback_template,base.group_system,1,1,0,0 +access_runbot_merge_patch,Patcher access,runbot_merge.model_runbot_merge_patch,runbot_merge.group_patcher,1,1,1,0 diff --git a/runbot_merge/security/security.xml b/runbot_merge/security/security.xml index d1ea1c79..668b2f02 100644 --- a/runbot_merge/security/security.xml +++ b/runbot_merge/security/security.xml @@ -1,9 +1,15 @@ + + Mergebot Patcher + Mergebot Administrator - + Mergebot Status Sender diff --git a/runbot_merge/tests/test_patching.py b/runbot_merge/tests/test_patching.py new file mode 100644 index 00000000..aa76f8e0 --- /dev/null +++ b/runbot_merge/tests/test_patching.py @@ -0,0 +1,240 @@ +import xmlrpc.client + +import pytest + +from utils import Commit + +# basic udiff / show style patch, updates `b` from `1` to `2` +BASIC_UDIFF = """\ +commit 0000000000000000000000000000000000000000 +Author: 3 Discos Down +Date: 2021-04-24T17:09:14Z + + whop + + whop whop + +diff --git a/b b/b +index 000000000000..000000000000 100644 +--- a/b ++++ b/b +@@ -1,1 +1,1 @@ +-1 ++2 +""" + +FIELD_MAPPING = { + 'boolean': ('old_value_integer', 'new_value_integer'), + 'date': ('old_value_datetime', 'new_value_datetime'), + 'datetime': ('old_value_datetime', 'new_value_datetime'), + 'char': ('old_value_char', 'new_value_char'), + 'float': ('old_value_float', 'new_value_float'), + 'integer': ('old_value_integer', 'new_value_integer'), + 'monetary': ('old_value_float', 'new_value_float'), + 'text': ('old_value_text', 'new_value_text'), +} +def read_tracking_value(tv): + field_id = tv.field_id if 'field_id' in tv else tv.field + field_type = field_id.field_type if 'field_type' in field_id else field_id.ttype + old_field_name, new_field_name = FIELD_MAPPING[field_type] + return field_id.name, tv[old_field_name], tv[new_field_name] + + +@pytest.fixture(autouse=True) +def _setup(repo): + with repo: + [c, _] = repo.make_commits( + None, + Commit("a", tree={"a": "1", "b": "1\n"}), + Commit("b", tree={"a": "2"}), + ref="heads/master", + ) + repo.make_ref("heads/x", c) + +@pytest.mark.parametrize("group,access", [ + ('base.group_portal', False), + ('base.group_user', False), + ('runbot_merge.group_patcher', True), + ('runbot_merge.group_admin', False), + ('base.group_system', True), +]) +def test_patch_acl(env, project, group, access): + g = env.ref(group) + assert g._name == 'res.groups' + env['res.users'].create({ + 'name': 'xxx', + 'login': 'xxx', + 'password': 'xxx', + 'groups_id': [(6, 0, [g.id])], + }) + env2 = env.with_user('xxx', 'xxx') + def create(): + return env2['runbot_merge.patch'].create({ + 'target': project.branch_ids.id, + 'repository': project.repo_ids.id, + 'patch': BASIC_UDIFF, + }) + if access: + create() + else: + pytest.raises(xmlrpc.client.Fault, create)\ + .match("You are not allowed to create") + +def test_apply_commit(env, project, repo, users): + with repo: + [c] = repo.make_commits("x", Commit("c", tree={"b": "2"}, author={ + 'name': "Henry Hoover", + "email": "dustsuckinghose@example.org", + }), ref="heads/abranch") + repo.delete_ref('heads/abranch') + + p = env['runbot_merge.patch'].create({ + 'target': project.branch_ids.id, + 'repository': project.repo_ids.id, + 'commit': c, + }) + + env.run_crons() + + HEAD = repo.commit('master') + assert repo.read_tree(HEAD) == { + 'a': '2', + 'b': '2', + } + assert HEAD.message == "c" + assert HEAD.author['name'] == "Henry Hoover" + assert HEAD.author['email'] == "dustsuckinghose@example.org" + assert not p.active + +def test_commit_conflict(env, project, repo, users): + with repo: + [c] = repo.make_commits("x", Commit("x", tree={"b": "3"})) + repo.make_commits("master", Commit("c", tree={"b": "2"}), ref="heads/master", make=False) + + p = env['runbot_merge.patch'].create({ + 'target': project.branch_ids.id, + 'repository': project.repo_ids.id, + 'commit': c, + }) + + env.run_crons() + + HEAD = repo.commit('master') + assert repo.read_tree(HEAD) == { + 'a': '2', + 'b': '2', + } + assert not p.active + assert [( + m.subject, + m.body, + list(map(read_tracking_value, m.tracking_value_ids)), + ) + for m in reversed(p.message_ids) + ] == [ + (False, '

Unstaged direct-application patch created

', []), + ( + "Unable to apply patch", + """\ +

Auto-merging b
\ +CONFLICT (content): Merge conflict in b

\ +""", + [], + ), + (False, '', [('active', 1, 0)]), + ] + +def test_apply_udiff(env, project, repo, users): + p = env['runbot_merge.patch'].create({ + 'target': project.branch_ids.id, + 'repository': project.repo_ids.id, + 'patch': BASIC_UDIFF, + }) + + env.run_crons() + + HEAD = repo.commit('master') + assert repo.read_tree(HEAD) == { + 'a': '2', + 'b': '2\n', + } + assert HEAD.message == "whop\n\nwhop whop" + assert HEAD.author['name'] == "3 Discos Down" + assert HEAD.author['email'] == "bar@example.org" + assert not p.active + + +def test_apply_format_patch(env, project, repo, users): + p = env['runbot_merge.patch'].create({ + 'target': project.branch_ids.id, + 'repository': project.repo_ids.id, + 'patch': """\ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: 3 Discos Down +Date: Sat, 24 Apr 2021 17:09:14 +0000 +Subject: [PATCH] whop + +whop whop +--- + b | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/b b/b +index 000000000000..000000000000 100644 +--- a/b ++++ b/b +@@ -1,1 +1,1 @@ +-1 ++2 +-- +2.46.2 +""", + }) + + env.run_crons() + + HEAD = repo.commit('master') + assert repo.read_tree(HEAD) == { + 'a': '2', + 'b': '2\n', + } + assert HEAD.message == "whop\n\nwhop whop" + assert HEAD.author['name'] == "3 Discos Down" + assert HEAD.author['email'] == "bar@example.org" + assert not p.active + +def test_patch_conflict(env, project, repo, users): + p = env['runbot_merge.patch'].create({ + 'target': project.branch_ids.id, + 'repository': project.repo_ids.id, + 'patch': BASIC_UDIFF, + }) + with repo: + repo.make_commits('master', Commit('cccombo breaker', tree={'b': '3'}), ref='heads/master', make=False) + + env.run_crons() + + HEAD = repo.commit('master') + assert HEAD.message == 'cccombo breaker' + assert repo.read_tree(HEAD) == { + 'a': '2', + 'b': '3', + } + assert not p.active + assert [( + m.subject, + m.body, + list(map(read_tracking_value, m.tracking_value_ids)), + ) + for m in reversed(p.message_ids) + ] == [( + False, + '

Unstaged direct-application patch created

', + [], + ), ( + "Unable to apply patch", + "

patching file b
Hunk #1 FAILED at 1.
1 out of 1 hunk FAILED -- saving rejects to file b.rej

", + [], + ), ( + False, '', [('active', 1, 0)] + )] diff --git a/runbot_merge/views/queues.xml b/runbot_merge/views/queues.xml index a72571f3..e18abaa1 100644 --- a/runbot_merge/views/queues.xml +++ b/runbot_merge/views/queues.xml @@ -81,17 +81,82 @@
- + + Patches + runbot_merge.patch + + + Patches Search + runbot_merge.patch + + + + + + + + + + Patches List + runbot_merge.patch + + + + + + + + + + Patches Form + runbot_merge.patch + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + +