From 6a1b77b92c6a3ca7fe1251389345a1ff2c7d7d90 Mon Sep 17 00:00:00 2001
From: Xavier Morel <xmo@odoo.com>
Date: Wed, 2 Oct 2024 12:14:09 +0200
Subject: [PATCH] [ADD] runbot_merge: support for unstaged patches

Unstaged changes can be useful or necessary for some tasks
e.g. absolute emergency (where even faking the state of a staging is
not really desirable, if that's even possible anymore), or changes
which are so broad they're difficult to stage (e.g. t10s updates).

Add a new object which serves as a queue for patch to direct-apply,
with support for either text patches (udiff style out of git show or
format-patch) or commits to cherry-pick. In the former case, the part
of the show / format-patch before the diff itself is used for the
commit metadata (author, committer, dates, message) whereas for the
commit version the commit itself is reused as-is.

Applied patches are simply disabled for traceability.

Fixes #926
---
 conftest.py                               |  25 +-
 runbot_merge/git.py                       |  54 ++--
 runbot_merge/models/__init__.py           |   1 +
 runbot_merge/models/patcher.py            | 311 ++++++++++++++++++++++
 runbot_merge/models/project.py            |  12 +
 runbot_merge/security/ir.model.access.csv |   4 +-
 runbot_merge/security/security.xml        |   8 +-
 runbot_merge/tests/test_patching.py       | 240 +++++++++++++++++
 runbot_merge/views/queues.xml             |  75 +++++-
 9 files changed, 695 insertions(+), 35 deletions(-)
 create mode 100644 runbot_merge/models/patcher.py
 create mode 100644 runbot_merge/tests/test_patching.py

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<file_from>\S+)(:?\s.*)?\n
+\+\+\+\x20b/(?P<file_to>\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 @@
 <odoo>
+    <record model="res.groups" id="group_patcher">
+        <field name="name">Mergebot Patcher</field>
+    </record>
     <record model="res.groups" id="group_admin">
         <field name="name">Mergebot Administrator</field>
     </record>
     <record model="res.groups" id="base.group_system">
-        <field name="implied_ids" eval="[(4, ref('runbot_merge.group_admin'))]"/>
+        <field name="implied_ids" eval="[
+            (4, ref('runbot_merge.group_admin')),
+            (4, ref('runbot_merge.group_patcher')),
+        ]"/>
     </record>
     <record model="res.groups" id="status">
         <field name="name">Mergebot Status Sender</field>
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 <bar@example.org>
+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, '<p>Unstaged direct-application patch created</p>', []),
+        (
+            "Unable to apply patch",
+            """\
+<p>Auto-merging b<br>\
+CONFLICT (content): Merge conflict in b<br></p>\
+""",
+            [],
+        ),
+        (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 <bar@example.org>
+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,
+        '<p>Unstaged direct-application patch created</p>',
+        [],
+    ), (
+        "Unable to apply patch",
+        "<p>patching file b<br>Hunk #1 FAILED at 1.<br>1 out of 1 hunk FAILED -- saving rejects to file b.rej<br></p>",
+        [],
+    ), (
+        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 @@
         </field>
     </record>
 
-    <menuitem name="Queues" id="menu_queues" parent="runbot_merge_menu"/>
+    <record id="action_patches" model="ir.actions.act_window">
+        <field name="name">Patches</field>
+        <field name="res_model">runbot_merge.patch</field>
+    </record>
+    <record id="search_patch" model="ir.ui.view">
+        <field name="name">Patches Search</field>
+        <field name="model">runbot_merge.patch</field>
+        <field name="arch" type="xml">
+            <search>
+                <filter string="Inactive" name="active" domain="[('active', '=', False)]"/>
+                <field name="target"/>
+                <field name="repository"/>
+            </search>
+        </field>
+    </record>
+    <record id="tree_patch" model="ir.ui.view">
+        <field name="name">Patches List</field>
+        <field name="model">runbot_merge.patch</field>
+        <field name="arch" type="xml">
+            <tree>
+                <field name="id"/>
+                <field name="repository"/>
+                <field name="target"/>
+            </tree>
+        </field>
+    </record>
+    <record id="form_patch" model="ir.ui.view">
+        <field name="name">Patches Form</field>
+        <field name="model">runbot_merge.patch</field>
+        <field name="arch" type="xml">
+            <form>
+                <sheet>
+                    <group>
+                        <group>
+                            <field name="repository"/>
+                            <field name="target"/>
+                        </group>
+                        <group>
+                            <field name="active"/>
+                        </group>
+                    </group>
+                    <group attrs="{'invisible': [
+                        ('commit', '=', False),
+                        ('patch', '!=', False),
+                    ]}">
+                        <group colspan="4">
+                            <field name="commit"/>
+                        </group>
+                    </group>
+                    <group attrs="{'invisible': [
+                        ('patch', '=', False),
+                        ('commit', '!=', False),
+                    ]}">
+                        <group colspan="4">
+                            <field name="format" colspan="4"/>
+                            <field name="patch" widget="ace"/>
+                                <!-- no diff/patch mode support -->
+                                <!-- options="{'mode': 'patch'}"/> -->
+                            <field name="message" colspan="4"/>
+                        </group>
+                    </group>
+                </sheet>
+            </form>
+        </field>
+    </record>
+
+    <menuitem name="Queues" id="menu_queues" parent="runbot_merge_menu">
         <menuitem name="Splits" id="menu_queues_splits"
-                  parent="menu_queues"
                   action="action_splits"/>
         <menuitem name="Feedback" id="menu_queues_feedback"
-                  parent="menu_queues"
                   action="action_feedback"/>
         <menuitem name="Tagging" id="menu_queues_tagging"
-                  parent="menu_queues"
                   action="action_tagging"/>
         <menuitem name="Fetches" id="menu_fetches"
-                  parent="menu_queues"
                   action="action_fetches"/>
+        <menuitem name="Patches" id="menu_patches"
+                  action="action_patches"/>
+    </menuitem>
 </odoo>