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})
|
||||
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
|
||||
)
|
||||
|
@ -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,29 +248,13 @@ 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
|
||||
for f, c in files.items():
|
||||
new_contents = c(repo, f)
|
||||
oid = repo \
|
||||
.with_config(input=f"""\
|
||||
<<<\x3c<<< HEAD
|
||||
||||||| MERGE BASE
|
||||
=======
|
||||
{contents}
|
||||
>>>\x3e>>> FORWARD PORTED
|
||||
""")\
|
||||
.with_config(input=new_contents) \
|
||||
.hash_object("-w", "--stdin", "--path", f) \
|
||||
.stdout.strip()
|
||||
|
||||
@ -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:
|
||||
|
@ -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
|
||||
|
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),
|
||||
('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:
|
||||
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
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>
|
||||
</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>
|
||||
|
Loading…
Reference in New Issue
Block a user