mirror of
https://github.com/odoo/runbot.git
synced 2025-03-15 15:35:46 +07:00
[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
This commit is contained in:
parent
aac987f2bb
commit
6a1b77b92c
25
conftest.py
25
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})
|
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
|
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):
|
def protect(self, branch):
|
||||||
assert self.hook
|
assert self.hook
|
||||||
r = self._session.put('https://api.github.com/repos/{}/branches/{}/protection'.format(self.name, branch), json={
|
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:
|
class Environment:
|
||||||
def __init__(self, port, db):
|
def __init__(self, port, db):
|
||||||
self._uid = xmlrpc.client.ServerProxy('http://localhost:{}/xmlrpc/2/common'.format(port)).authenticate(db, 'admin', 'admin', {})
|
self._port = port
|
||||||
self._object = xmlrpc.client.ServerProxy('http://localhost:{}/xmlrpc/2/object'.format(port))
|
|
||||||
self._db = db
|
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):
|
def __call__(self, model, method, *args, **kwargs):
|
||||||
return self._object.execute_kw(
|
return self._object.execute_kw(
|
||||||
self._db, self._uid, 'admin',
|
self._db, self._uid, self._password,
|
||||||
model, method,
|
model, method,
|
||||||
args, kwargs
|
args, kwargs
|
||||||
)
|
)
|
||||||
|
@ -7,7 +7,8 @@ import resource
|
|||||||
import stat
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
from operator import methodcaller
|
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 odoo.tools.appdirs import user_cache_dir
|
||||||
from .github import MergeError, PrCommit
|
from .github import MergeError, PrCommit
|
||||||
@ -76,6 +77,7 @@ class Repo:
|
|||||||
config.setdefault('stderr', subprocess.PIPE)
|
config.setdefault('stderr', subprocess.PIPE)
|
||||||
self._config = config
|
self._config = config
|
||||||
self._params = ()
|
self._params = ()
|
||||||
|
self.runner = subprocess.run
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> 'GitCommand':
|
def __getattr__(self, name: str) -> 'GitCommand':
|
||||||
return GitCommand(self, name.replace('_', '-'))
|
return GitCommand(self, name.replace('_', '-'))
|
||||||
@ -86,7 +88,7 @@ class Repo:
|
|||||||
+ tuple(itertools.chain.from_iterable(('-c', p) for p in self._params + ALWAYS))\
|
+ tuple(itertools.chain.from_iterable(('-c', p) for p in self._params + ALWAYS))\
|
||||||
+ args
|
+ args
|
||||||
try:
|
try:
|
||||||
return subprocess.run(args, preexec_fn=_bypass_limits, **opts)
|
return self.runner(args, preexec_fn=_bypass_limits, **opts)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
stream = e.stderr or e.stdout
|
stream = e.stderr or e.stdout
|
||||||
if stream:
|
if stream:
|
||||||
@ -246,30 +248,14 @@ class Repo:
|
|||||||
*itertools.chain.from_iterable(('-p', p) for p in parents),
|
*itertools.chain.from_iterable(('-p', p) for p in parents),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def update_tree(self, tree: str, files: Mapping[str, Callable[[Self, str], str]]) -> str:
|
||||||
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
|
|
||||||
"""
|
|
||||||
# FIXME: either ignore or process binary files somehow (how does git show conflicts in binary files?)
|
# 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")
|
repo = self.stdout().with_config(stderr=None, text=True, check=False, encoding="utf-8")
|
||||||
for f in files:
|
for f, c in files.items():
|
||||||
contents = repo.cat_file("-p", f"{tree}:{f}").stdout
|
new_contents = c(repo, f)
|
||||||
# decorate the contents as if HEAD and BASE are empty
|
oid = repo \
|
||||||
oid = repo\
|
.with_config(input=new_contents) \
|
||||||
.with_config(input=f"""\
|
.hash_object("-w", "--stdin", "--path", f) \
|
||||||
<<<\x3c<<< HEAD
|
|
||||||
||||||| MERGE BASE
|
|
||||||
=======
|
|
||||||
{contents}
|
|
||||||
>>>\x3e>>> FORWARD PORTED
|
|
||||||
""")\
|
|
||||||
.hash_object("-w", "--stdin", "--path", f)\
|
|
||||||
.stdout.strip()
|
.stdout.strip()
|
||||||
|
|
||||||
# we need to rewrite every tree from the parent of `f`
|
# we need to rewrite every tree from the parent of `f`
|
||||||
@ -286,6 +272,26 @@ class Repo:
|
|||||||
tree = oid
|
tree = oid
|
||||||
return tree
|
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:
|
def check(p: subprocess.CompletedProcess) -> subprocess.CompletedProcess:
|
||||||
if not p.returncode:
|
if not p.returncode:
|
||||||
|
@ -4,6 +4,7 @@ from . import res_partner
|
|||||||
from . import project
|
from . import project
|
||||||
from . import pull_requests
|
from . import pull_requests
|
||||||
from . import batch
|
from . import batch
|
||||||
|
from . import patcher
|
||||||
from . import project_freeze
|
from . import project_freeze
|
||||||
from . import stagings_create
|
from . import stagings_create
|
||||||
from . import staging_cancel
|
from . import staging_cancel
|
||||||
|
311
runbot_merge/models/patcher.py
Normal file
311
runbot_merge/models/patcher.py
Normal file
@ -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()
|
@ -147,6 +147,18 @@ class Project(models.Model):
|
|||||||
('staging_enabled', '=', True),
|
('staging_enabled', '=', True),
|
||||||
('project_id.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:
|
try:
|
||||||
with self.env.cr.savepoint(), \
|
with self.env.cr.savepoint(), \
|
||||||
sentry_sdk.start_span(description=f'create staging {branch.name}') as span:
|
sentry_sdk.start_span(description=f'create staging {branch.name}') as span:
|
||||||
|
@ -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_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_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
|
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
|
||||||
|
|
@ -1,9 +1,15 @@
|
|||||||
<odoo>
|
<odoo>
|
||||||
|
<record model="res.groups" id="group_patcher">
|
||||||
|
<field name="name">Mergebot Patcher</field>
|
||||||
|
</record>
|
||||||
<record model="res.groups" id="group_admin">
|
<record model="res.groups" id="group_admin">
|
||||||
<field name="name">Mergebot Administrator</field>
|
<field name="name">Mergebot Administrator</field>
|
||||||
</record>
|
</record>
|
||||||
<record model="res.groups" id="base.group_system">
|
<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>
|
||||||
<record model="res.groups" id="status">
|
<record model="res.groups" id="status">
|
||||||
<field name="name">Mergebot Status Sender</field>
|
<field name="name">Mergebot Status Sender</field>
|
||||||
|
240
runbot_merge/tests/test_patching.py
Normal file
240
runbot_merge/tests/test_patching.py
Normal file
@ -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)]
|
||||||
|
)]
|
@ -81,17 +81,82 @@
|
|||||||
</field>
|
</field>
|
||||||
</record>
|
</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"
|
<menuitem name="Splits" id="menu_queues_splits"
|
||||||
parent="menu_queues"
|
|
||||||
action="action_splits"/>
|
action="action_splits"/>
|
||||||
<menuitem name="Feedback" id="menu_queues_feedback"
|
<menuitem name="Feedback" id="menu_queues_feedback"
|
||||||
parent="menu_queues"
|
|
||||||
action="action_feedback"/>
|
action="action_feedback"/>
|
||||||
<menuitem name="Tagging" id="menu_queues_tagging"
|
<menuitem name="Tagging" id="menu_queues_tagging"
|
||||||
parent="menu_queues"
|
|
||||||
action="action_tagging"/>
|
action="action_tagging"/>
|
||||||
<menuitem name="Fetches" id="menu_fetches"
|
<menuitem name="Fetches" id="menu_fetches"
|
||||||
parent="menu_queues"
|
|
||||||
action="action_fetches"/>
|
action="action_fetches"/>
|
||||||
|
<menuitem name="Patches" id="menu_patches"
|
||||||
|
action="action_patches"/>
|
||||||
|
</menuitem>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
Loading…
Reference in New Issue
Block a user