[IMP] forwardport: handling of updates causing conflicts on followups

If a PR is updated and has extent forward-ports, those forwardports
get updated automatically ("followup").

However there is an issue if the udpate causes a conflict in the
followup: the conflict gets silently pushed, and may fairly easily get
merged if it occurs in an area which the CI doesn't cover.

It's unclear what the policy really should be for this issue, and
there is no real way to *block* a pull request at the moment (save by
putting it in error at the mergebot level I guess?), so for now
clearly notify the user on both the modified PR and the followup,
with a comment on both.

We may want to revisit this eventually.

Fixes #467
This commit is contained in:
Xavier Morel 2021-07-27 09:17:18 +02:00 committed by xmo-odoo
parent f10d33ee85
commit 6b1f698c23
2 changed files with 124 additions and 57 deletions

View File

@ -79,6 +79,15 @@ class BatchQueue(models.Model, Queue):
) )
batch.active = False batch.active = False
CONFLICT_TEMPLATE = "WARNING: the latest change ({previous.head}) triggered " \
"a conflict when updating the next forward-port " \
"({next.display_name}), and has been ignored.\n\n" \
"You will need to update this pull request differently, " \
"or fix the issue by hand on {next.display_name}."
CHILD_CONFLICT = "WARNING: the update of {previous.display_name} to " \
"{previous.head} has caused a conflict in this pull request, " \
"data may have been lost."
class UpdateQueue(models.Model, Queue): class UpdateQueue(models.Model, Queue):
_name = 'forwardport.updates' _name = 'forwardport.updates'
_description = 'if a forward-port PR gets updated & has followups (cherrypick succeeded) the followups need to be updated as well' _description = 'if a forward-port PR gets updated & has followups (cherrypick succeeded) the followups need to be updated as well'
@ -89,6 +98,7 @@ class UpdateQueue(models.Model, Queue):
new_root = fields.Many2one('runbot_merge.pull_requests') new_root = fields.Many2one('runbot_merge.pull_requests')
def _process_item(self): def _process_item(self):
Feedback = self.env['runbot_merge.pull_requests.feedback']
previous = self.new_root previous = self.new_root
with ExitStack() as s: with ExitStack() as s:
for child in self.new_root._iter_descendants(): for child in self.new_root._iter_descendants():
@ -100,7 +110,7 @@ class UpdateQueue(models.Model, Queue):
self.new_root.display_name self.new_root.display_name
) )
if child.state in ('closed', 'merged'): if child.state in ('closed', 'merged'):
self.env['runbot_merge.pull_requests.feedback'].create({ Feedback.create({
'repository': child.repository.id, 'repository': child.repository.id,
'pull_request': child.number, 'pull_request': child.number,
'message': "Ancestor PR %s has been updated but this PR" 'message': "Ancestor PR %s has been updated but this PR"
@ -108,14 +118,31 @@ class UpdateQueue(models.Model, Queue):
"\n\n" "\n\n"
"You may want or need to manually update any" "You may want or need to manually update any"
" followup PR." % ( " followup PR." % (
self.new_root.display_name, self.new_root.display_name,
child.state, child.state,
) )
}) })
return return
# QUESTION: update PR to draft if there are conflicts? # QUESTION: update PR to draft if there are conflicts?
_, working_copy = previous._create_fp_branch( conflicts, working_copy = previous._create_fp_branch(
child.target, child.refname, s) child.target, child.refname, s)
if conflicts:
_, out, err = conflicts
Feedback.create({
'repository': previous.repository.id,
'pull_request': previous.number,
'message': CONFLICT_TEMPLATE.format(
previous=previous,
next=child
)
})
Feedback.create({
'repository': child.repository.id,
'pull_request': child.number,
'message': CHILD_CONFLICT.format(previous=previous, next=child)\
+ (f'\n\nstdout:\n```\n{out.strip()}\n```' if out.strip() else '')
+ (f'\n\nstderr:\n```\n{err.strip()}\n```' if err.strip() else '')
})
new_head = working_copy.stdout().rev_parse(child.refname).stdout.decode().strip() new_head = working_copy.stdout().rev_parse(child.refname).stdout.decode().strip()
commits_count = int(working_copy.stdout().rev_list( commits_count = int(working_copy.stdout().rev_list(

View File

@ -2,11 +2,13 @@
Test cases for updating PRs during after the forward-porting process after the Test cases for updating PRs during after the forward-porting process after the
initial merge has succeeded (and forward-porting has started) initial merge has succeeded (and forward-porting has started)
""" """
import re
import sys import sys
import pytest import pytest
from utils import seen, re_matches, Commit, make_basic from utils import seen, re_matches, Commit, make_basic, to_pr
def test_update_pr(env, config, make_repo, users): def test_update_pr(env, config, make_repo, users):
""" Even for successful cherrypicks, it's possible that e.g. CI doesn't """ Even for successful cherrypicks, it's possible that e.g. CI doesn't
@ -119,57 +121,6 @@ More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
'x': '5' 'x': '5'
}, "the followup FP should also have the update" }, "the followup FP should also have the update"
with pr_repo:
pr_repo.make_commits(
pr1_id.target.name,
Commit('fire!', tree={'h': '0'}),
ref='heads/%s' % pr_ref,
)
env.run_crons()
# since there are PRs, this is going to update pr2 as broken
assert prod.read_tree(prod.commit(pr1_id.head)) == {
'f': 'c',
'g': 'b',
'h': '0'
}
assert prod.read_tree(prod.commit(pr2_id.head)) == {
'f': 'c',
'g': 'a',
'h': re_matches(r'''<<<\x3c<<< HEAD
a
=======
0
>>>\x3e>>> [0-9a-f]{7,}.*
'''),
}
[project] = env['runbot_merge.project'].search([])
pr2 = prod.get_pr(pr2_id.number)
# fail pr2 then fwbot r+ to check that we get a warning
with prod:
prod.post_status(pr2_id.head, 'failure', 'ci/runbot')
env.run_crons() # parse commit statuses
with prod:
pr2.post_comment(project.fp_github_name + ' r+', config['role_reviewer']['token'])
env.run_crons() # send feedback
assert pr2.comments == [
seen(env, pr2, users),
(users['user'], """Ping @{}, @{}
This PR targets c and is the last of the forward-port chain containing:
* {}
To merge the full chain, say
> @{} r+
More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
""".format(users['user'], users['reviewer'], pr1_id.display_name, project.fp_github_name)),
(users['user'], 'Ping @{}, @{}\n\nci/runbot failed on this forward-port PR'.format(
users['user'], users['reviewer']
)),
(users['reviewer'], project.fp_github_name + ' r+'),
(users['user'], '@{}, you may want to rebuild or fix this PR as it has failed CI.'.format(users['reviewer'])),
]
def test_update_merged(env, make_repo, config, users): def test_update_merged(env, make_repo, config, users):
""" Strange things happen when an FP gets closed / merged but then its """ Strange things happen when an FP gets closed / merged but then its
parent is modified and the forwardport tries to update the (now merged) parent is modified and the forwardport tries to update the (now merged)
@ -365,3 +316,92 @@ def test_duplicate_fw(env, make_repo, setreviewers, config, users):
env.run_crons() env.run_crons()
# env.run_crons() # env.run_crons()
assert PRs.search([], order='number') == pr_ids assert PRs.search([], order='number') == pr_ids
def test_subsequent_conflict(env, make_repo, config, users):
""" Test for updating an fw PR in the case where it produces a conflict in
the followup. Cf #467.
"""
repo, fork = make_basic(env, config, make_repo)
# create a PR in branch A which adds a new file
with repo:
repo.make_commits('a', Commit('newfile', tree={'x': '0'}), ref='heads/pr1')
pr_1 = repo.make_pr(target='a', head='pr1')
repo.post_status('pr1', 'success', 'legal/cla')
repo.post_status('pr1', 'success', 'ci/runbot')
pr_1.post_comment('hansen r+', config['role_reviewer']['token'])
env.run_crons()
with repo:
repo.post_status('staging.a', 'success', 'legal/cla')
repo.post_status('staging.a', 'success', 'ci/runbot')
env.run_crons()
pr1_id = to_pr(env, pr_1)
assert pr1_id.state == 'merged'
pr2_id = env['runbot_merge.pull_requests'].search([('source_id', '=', pr1_id.id)])
assert pr2_id
with repo:
repo.post_status(pr2_id.head, 'success', 'legal/cla')
repo.post_status(pr2_id.head, 'success', 'ci/runbot')
env.run_crons()
pr3_id = env['runbot_merge.pull_requests'].search([('parent_id', '=', pr2_id.id)])
assert pr3_id
assert repo.read_tree(repo.commit(pr3_id.head)) == {
'f': 'c',
'g': 'a',
'h': 'a',
'x': '0',
}
# update pr2: add a file "h"
pr2 = repo.get_pr(pr2_id.number)
t = {**repo.read_tree(repo.commit(pr2_id.head)), 'h': 'conflict!'}
with fork:
fork.make_commits(pr2_id.target.name, Commit('newfiles', tree=t), ref=pr2.ref, make=False)
env.run_crons()
assert repo.read_tree(repo.commit(pr3_id.head)) == {
'f': 'c',
'g': 'a',
'h': re_matches(r'''<<<\x3c<<< HEAD
a
=======
conflict!
>>>\x3e>>> [0-9a-f]{7,}.*
'''),
'x': '0',
}
# skip comments:
# 1. link to mergebot status page
# 2. "forward port chain" bit
# 3. updated / modified & got detached
assert pr2.comments[3:] == [
(users['user'], f"WARNING: the latest change ({pr2_id.head}) triggered "
f"a conflict when updating the next forward-port "
f"({pr3_id.display_name}), and has been ignored.\n\n"
f"You will need to update this pull request "
f"differently, or fix the issue by hand on "
f"{pr3_id.display_name}.")
]
# skip comments:
# 1. link to status page
# 2. forward-port chain thing
assert repo.get_pr(pr3_id.number).comments[2:] == [
(users['user'], re_matches(f'''\
WARNING: the update of {pr2_id.display_name} to {pr2_id.head} has caused a \
conflict in this pull request, data may have been lost.
stdout:
```
CONFLICT \(add/add\): Merge conflict in h
Auto-merging h
```
stderr:
```
\\d{{2}}:\\d{{2}}:\\d{{2}}.\\d+ .* {pr2_id.head}
error: could not apply [0-9a-f]+\\.\\.\\. newfiles
hint: after resolving the conflicts, mark the corrected paths
.*''', re.DOTALL))
]