[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:
Xavier Morel 2024-10-02 12:14:09 +02:00
parent aac987f2bb
commit 6a1b77b92c
9 changed files with 695 additions and 35 deletions

View File

@ -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
)

View File

@ -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:

View File

@ -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

View 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()

View File

@ -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:

View File

@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
30 access_runbot_merge_pull_requests_feedback Users have no reason to access feedback model_runbot_merge_pull_requests_feedback 0 0 0 0
31 access_runbot_merge_review_rights_2 Users can see partners model_res_partner_review base.group_user 1 0 0 0
32 access_runbot_merge_review_override_2 Users can see partners model_res_partner_override base.group_user 1 0 0 0
33 runbot_merge.access_runbot_merge_pull_requests_feedback_template 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
34 access_runbot_merge_patch Patcher access runbot_merge.model_runbot_merge_patch runbot_merge.group_patcher 1 1 1 0

View File

@ -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>

View 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)]
)]

View File

@ -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>