[IMP] runbot_merge: prioritize p0 more

* p0 cancel existing stagings in order to be staged as soon as
  possible
* p0 PRs should be picked over split batches
* p0 bypass PR-level CI and review requirements
* p0 can be set on any of a batch's PR, matched PRs will be staged
  alongside even if their priority is the default
This commit is contained in:
Xavier Morel 2018-03-27 13:33:04 +02:00 committed by xmo-odoo
parent e6f5b84a19
commit 49c8fdbed2
4 changed files with 161 additions and 85 deletions

View File

@ -132,16 +132,11 @@ def handle_pr(event):
pr_obj.state = 'opened' pr_obj.state = 'opened'
elif pr_obj.state == 'ready': elif pr_obj.state == 'ready':
pr_obj.state = 'approved' pr_obj.state = 'approved'
if pr_obj.staging_id: pr_obj.staging_id.cancel(
_logger.info( "Updated PR %s:%s, removing staging %s",
"Updated PR %s:%s, removing staging %s", pr_obj.repository.name, pr_obj.number,
pr_obj.repository.name, pr_obj.number, pr_obj.staging_id,
pr_obj.staging_id, )
)
# immediately cancel the staging?
staging = pr_obj.staging_id
staging.batch_ids.unlink()
staging.unlink()
# TODO: should we update squash as well? What of explicit squash commands? # TODO: should we update squash as well? What of explicit squash commands?
pr_obj.head = pr['head']['sha'] pr_obj.head = pr['head']['sha']

View File

@ -113,39 +113,42 @@ class Project(models.Model):
if branch.active_staging_id: if branch.active_staging_id:
continue continue
# Splits can generate inactive stagings, restage these first self.env.cr.execute("""
if branch.staging_ids: SELECT
min(pr.priority) as priority,
array_agg(pr.id) AS match
FROM runbot_merge_pull_requests pr
WHERE pr.target = %s
AND pr.batch_id IS NULL
-- exclude terminal states (so there's no issue when
-- deleting branches & reusing labels)
AND pr.state != 'merged'
AND pr.state != 'closed'
GROUP BY pr.label
HAVING bool_or(pr.priority = 0)
OR bool_and(pr.state = 'ready')
ORDER BY min(pr.priority), min(pr.id)
""", [branch.id])
# result: [(priority, [(repo_id, pr_id) for repo in repos]
rows = self.env.cr.fetchall()
priority = rows[0][0] if rows else -1
if priority == 0:
# p=0 take precedence over all else
batches = [
PRs.browse(pr_ids)
for _, pr_ids in takewhile(lambda r: r[0] == priority, rows)
]
elif branch.staging_ids:
# Splits can generate inactive stagings, restage these first
staging = branch.staging_ids[0] staging = branch.staging_ids[0]
logger.info("Found inactive staging %s, reactivating", staging) logger.info("Found inactive staging %s, reactivating", staging)
batches = [batch.prs for batch in staging.batch_ids] batches = [batch.prs for batch in staging.batch_ids]
staging.unlink() staging.unlink()
else: elif rows:
self.env.cr.execute(""" # p=1 or p=2
SELECT
min(pr.priority) as priority,
array_agg(pr.id) AS match
FROM runbot_merge_pull_requests pr
WHERE pr.target = %s
AND pr.batch_id IS NULL
-- exclude terminal states (so there's no issue when
-- deleting branches & reusing labels)
AND pr.state != 'merged'
AND pr.state != 'closed'
GROUP BY pr.label
HAVING every(pr.state = 'ready')
ORDER BY min(pr.priority), min(pr.id)
""", [branch.id])
# result: [(priority, [(repo_id, pr_id) for repo in repos]
rows = self.env.cr.fetchall()
logger.info(
"Looking for PRs to stage for %s... %s",
branch.name, rows
)
if not rows:
continue
priority = rows[0][0]
batches = [PRs.browse(pr_ids) for _, pr_ids in takewhile(lambda r: r[0] == priority, rows)] batches = [PRs.browse(pr_ids) for _, pr_ids in takewhile(lambda r: r[0] == priority, rows)]
else:
continue
staged = Batch staged = Batch
meta = {repo: {} for repo in project.repo_ids} meta = {repo: {} for repo in project.repo_ids}
@ -454,6 +457,12 @@ class PullRequests(models.Model):
if is_admin: if is_admin:
ok = True ok = True
self.priority = param self.priority = param
self.target.active_staging_id.cancel(
"P=0 on %s:%s by %s, unstaging %s",
self.repository.name, self.number,
author.github_login, self.target.name,
)
_logger.info( _logger.info(
"%s %s(%s) on %s:%s by %s (%s)", "%s %s(%s) on %s:%s by %s (%s)",
"applied" if ok else "ignored", "applied" if ok else "ignored",
@ -585,6 +594,14 @@ class Stagings(models.Model):
assert v == 'success' assert v == 'success'
s.state = st s.state = st
def cancel(self, reason, *args):
if not self:
return
_logger.info(reason, *args)
self.batch_ids.unlink()
self.unlink()
def fail(self, message, prs=None): def fail(self, message, prs=None):
_logger.error("Staging %s failed: %s", self, message) _logger.error("Staging %s failed: %s", self, message)
prs = prs or self.batch_ids.prs prs = prs or self.batch_ids.prs

View File

@ -813,13 +813,20 @@ class TestPRUpdate(object):
assert not env['runbot_merge.pull_requests'].search([('number', '=', prx.number)]) assert not env['runbot_merge.pull_requests'].search([('number', '=', prx.number)])
class TestBatching(object): class TestBatching(object):
def _pr(self, repo, prefix, trees, target='master', user='user'): def _pr(self, repo, prefix, trees, *,
target='master', user='user', reviewer='reviewer',
statuses=(('ci/runbot', 'success'), ('legal/cla', 'success'))
):
""" Helper creating a PR from a series of commits on a base """ Helper creating a PR from a series of commits on a base
:param prefix: a prefix used for commit messages, PR title & PR body :param prefix: a prefix used for commit messages, PR title & PR body
:param trees: a list of dicts symbolising the tree for the corresponding commit. :param trees: a list of dicts symbolising the tree for the corresponding commit.
each tree is an update on the "current state" of the tree each tree is an update on the "current state" of the tree
:param target: branch, both the base commit and the PR target :param target: branch, both the base commit and the PR target
:type target: str
:type user: str
:type reviewer: str | None
:type statuses: List[(str, str)]
""" """
base = repo.commit('heads/{}'.format(target)) base = repo.commit('heads/{}'.format(target))
tree = dict(repo.objects[base.tree]) tree = dict(repo.objects[base.tree])
@ -828,9 +835,11 @@ class TestBatching(object):
tree.update(t) tree.update(t)
c = repo.make_commit(c, 'commit_{}_{:02}'.format(prefix, i), None, tree=dict(tree)) c = repo.make_commit(c, 'commit_{}_{:02}'.format(prefix, i), None, tree=dict(tree))
pr = repo.make_pr('title {}'.format(prefix), 'body {}'.format(prefix), target=target, ctid=c, user=user, label='{}:{}'.format(user, prefix)) pr = repo.make_pr('title {}'.format(prefix), 'body {}'.format(prefix), target=target, ctid=c, user=user, label='{}:{}'.format(user, prefix))
repo.post_status(c, 'success', 'ci/runbot')
repo.post_status(c, 'success', 'legal/cla') for context, result in statuses:
pr.post_comment('hansen r+', 'reviewer') repo.post_status(c, result, context)
if reviewer:
pr.post_comment('hansen r+', reviewer)
return pr return pr
def _get(self, env, number): def _get(self, env, number):
@ -885,10 +894,6 @@ class TestBatching(object):
assert pr11.staging_id == pr12.staging_id assert pr11.staging_id == pr12.staging_id
def test_batching_urgent(self, env, repo): def test_batching_urgent(self, env, repo):
""" "Urgent" PRs should be selected before pressing & normal & batched together (?)
TODO: should they also ignore validation aka immediately staged?
"""
m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'}) m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'})
repo.make_ref('heads/master', m) repo.make_ref('heads/master', m)
@ -900,27 +905,64 @@ class TestBatching(object):
pr11.post_comment('hansen priority=1', 'reviewer') pr11.post_comment('hansen priority=1', 'reviewer')
pr12.post_comment('hansen priority=1', 'reviewer') pr12.post_comment('hansen priority=1', 'reviewer')
pr01 = self._pr(repo, 'Urgent 1', [{'n': 'n'}, {'o': 'o'}]) # stage PR1
pr02 = self._pr(repo, 'Urgent 2', [{'p': 'p'}, {'q': 'q'}]) env['runbot_merge.project']._check_progress()
pr01.post_comment('hansen priority=0', 'reviewer') p_11, p_12, p_21, p_22 = \
pr02.post_comment('hansen priority=0', 'reviewer') [self._get(env, pr.number) for pr in [pr11, pr12, pr21, pr22]]
assert not p_21.staging_id or p_22.staging_id
assert p_11.staging_id and p_12.staging_id
assert p_11.staging_id == p_12.staging_id
staging_1 = p_11.staging_id
pr01, pr02, pr11, pr12, pr21, pr22 = prs = \ # no statuses run on PR0s
[self._get(env, pr.number) for pr in [pr01, pr02, pr11, pr12, pr21, pr22]] pr01 = self._pr(repo, 'Urgent 1', [{'n': 'n'}, {'o': 'o'}], reviewer=None, statuses=[])
assert pr01.priority == pr02.priority == 0 pr01.post_comment('hansen priority=0', 'reviewer')
assert pr11.priority == pr12.priority == 1 p_01 = self._get(env, pr01.number)
assert pr21.priority == pr22.priority == 2 assert p_01.state == 'opened'
assert p_01.priority == 0
env['runbot_merge.project']._check_progress() env['runbot_merge.project']._check_progress()
# first staging should be cancelled and PR0 should be staged
# regardless of CI (or lack thereof)
assert not staging_1.exists()
assert not p_11.staging_id and not p_12.staging_id
assert p_01.staging_id
def test_batching_urgenter_than_split(self, env, repo):
""" p=0 PRs should take priority over split stagings (processing
of a staging having CI-failed and being split into sub-stagings)
"""
m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'})
repo.make_ref('heads/master', m)
pr1 = self._pr(repo, 'PR1', [{'a': 'AAA'}, {'b': 'BBB'}])
p_1 = self._get(env, pr1.number)
pr2 = self._pr(repo, 'PR2', [{'a': 'some_content', 'c': 'CCC'}, {'d': 'DDD'}])
p_2 = self._get(env, pr2.number)
env['runbot_merge.project']._check_progress()
st = env['runbot_merge.stagings'].search([])
# both prs should be part of the staging
assert st.mapped('batch_ids.prs') == p_1 | p_2
# add CI failure
repo.post_status('heads/staging.master', 'failure', 'ci/runbot')
repo.post_status('heads/staging.master', 'success', 'legal/cla')
env['runbot_merge.project']._check_progress()
# should have staged the first half
assert p_1.staging_id.heads
assert not p_2.staging_id.heads
# during restaging of pr1, create urgent PR
pr0 = self._pr(repo, 'urgent', [{'a': 'a', 'b': 'b'}], reviewer=None, statuses=[])
pr0.post_comment('hansen priority=0', 'reviewer')
env['runbot_merge.project']._check_progress()
# TODO: maybe just deactivate stagings instead of deleting them when canceling?
assert not p_1.staging_id
assert self._get(env, pr0.number).staging_id
assert all(pr.state == 'ready' for pr in prs)
assert pr01.staging_id
assert pr02.staging_id
assert pr01.staging_id == pr02.staging_id
assert not pr11.staging_id
assert not pr12.staging_id
assert not pr21.staging_id
assert not pr22.staging_id
@pytest.mark.skip(reason="Maybe nothing to do, the PR is just skipped and put in error?") @pytest.mark.skip(reason="Maybe nothing to do, the PR is just skipped and put in error?")
def test_batching_merge_failure(self, env, repo): def test_batching_merge_failure(self, env, repo):
@ -932,28 +974,16 @@ class TestBatching(object):
m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'}) m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'})
repo.make_ref('heads/master', m) repo.make_ref('heads/master', m)
c10 = repo.make_commit(m, 'AAA', None, tree={'a': 'AAA'}) pr1 = self._pr(repo, 'PR1', [{'a': 'AAA'}, {'b': 'BBB'}])
c11 = repo.make_commit(c10, 'BBB', None, tree={'a': 'AAA', 'b': 'BBB'}) pr2 = self._pr(repo, 'PR2', [{'a': 'some_content', 'c': 'CCC'}, {'d': 'DDD'}])
pr1 = repo.make_pr('t1', 'b1', target='master', ctid=c11, user='user', label='user:a')
repo.post_status(pr1.head, 'success', 'ci/runbot')
repo.post_status(pr1.head, 'success', 'legal/cla')
pr1.post_comment('hansen r+', "reviewer")
c20 = repo.make_commit(m, 'CCC', None, tree={'a': 'some content', 'c': 'CCC'})
c21 = repo.make_commit(c20, 'DDD', None, tree={'a': 'some content', 'c': 'CCC', 'd': 'DDD'})
pr2 = repo.make_pr('t2', 'b2', target='master', ctid=c21, user='user', label='user:b')
repo.post_status(pr2.head, 'success', 'ci/runbot')
repo.post_status(pr2.head, 'success', 'legal/cla')
pr2.post_comment('hansen r+', "reviewer")
env['runbot_merge.project']._check_progress() env['runbot_merge.project']._check_progress()
st = env['runbot_merge.stagings'].search([]) st = env['runbot_merge.stagings'].search([])
# both prs should be part of the staging # both prs should be part of the staging
assert len(st.mapped('batch_ids.prs')) == 2 assert len(st.mapped('batch_ids.prs')) == 2
# add CI failure # add CI failure
h = repo.commit('heads/staging.master').id repo.post_status('heads/staging.master', 'failure', 'ci/runbot')
repo.post_status(h, 'failure', 'ci/runbot') repo.post_status('heads/staging.master', 'success', 'legal/cla')
repo.post_status(h, 'success', 'legal/cla')
pr1 = env['runbot_merge.pull_requests'].search([('number', '=', pr1.number)]) pr1 = env['runbot_merge.pull_requests'].search([('number', '=', pr1.number)])
pr2 = env['runbot_merge.pull_requests'].search([('number', '=', pr2.number)]) pr2 = env['runbot_merge.pull_requests'].search([('number', '=', pr2.number)])

View File

@ -11,8 +11,6 @@ import odoo
import pytest import pytest
from fake_github import git
@pytest.fixture @pytest.fixture
def project(env): def project(env):
env['res.partner'].create({ env['res.partner'].create({
@ -54,7 +52,20 @@ def repo_c(gh, project):
((odoo.http.root, '/runbot_merge/hooks'), ['pull_request', 'issue_comment', 'status']) ((odoo.http.root, '/runbot_merge/hooks'), ['pull_request', 'issue_comment', 'status'])
]) ])
def make_pr(repo, prefix, trees, target='master', user='user', label=None): def make_pr(repo, prefix, trees, *, target='master', user='user', label=None,
statuses=(('ci/runbot', 'success'), ('legal/cla', 'success')),
reviewer='reviewer'):
"""
:type repo: fake_github.Repo
:type prefix: str
:type trees: list[dict]
:type target: str
:type user: str
:type label: str | None
:type statuses: list[(str, str)]
:type reviewer: str | None
:rtype: fake_github.PR
"""
base = repo.commit('heads/{}'.format(target)) base = repo.commit('heads/{}'.format(target))
tree = dict(repo.objects[base.tree]) tree = dict(repo.objects[base.tree])
c = base.id c = base.id
@ -64,9 +75,10 @@ def make_pr(repo, prefix, trees, target='master', user='user', label=None):
tree=dict(tree)) tree=dict(tree))
pr = repo.make_pr('title {}'.format(prefix), 'body {}'.format(prefix), target=target, pr = repo.make_pr('title {}'.format(prefix), 'body {}'.format(prefix), target=target,
ctid=c, user=user, label=label and '{}:{}'.format(user, label)) ctid=c, user=user, label=label and '{}:{}'.format(user, label))
repo.post_status(c, 'success', 'ci/runbot') for context, result in statuses:
repo.post_status(c, 'success', 'legal/cla') repo.post_status(c, result, context)
pr.post_comment('hansen r+', 'reviewer') if reviewer:
pr.post_comment('hansen r+', reviewer)
return pr return pr
def to_pr(env, pr): def to_pr(env, pr):
return env['runbot_merge.pull_requests'].search([ return env['runbot_merge.pull_requests'].search([
@ -344,3 +356,25 @@ def test_batching_split(env, repo_a, repo_b):
assert len(st2.batch_ids) == 2 assert len(st2.batch_ids) == 2
assert st2.mapped('batch_ids.prs') == \ assert st2.mapped('batch_ids.prs') == \
prs[0][0] | prs[0][1] | prs[1][1] prs[0][0] | prs[0][1] | prs[1][1]
def test_urgent(env, repo_a, repo_b):
""" Either PR of a co-dependent pair being p=0 leads to the entire pair
being prioritized
"""
repo_a.make_ref('heads/master', repo_a.make_commit(None, 'initial', None, tree={'a0': 'a'}))
repo_b.make_ref('heads/master', repo_b.make_commit(None, 'initial', None, tree={'b0': 'b'}))
pr_a = make_pr(repo_a, 'A', [{'a1': 'a'}, {'a2': 'a'}], label='batch', reviewer=None, statuses=[])
pr_b = make_pr(repo_b, 'B', [{'b1': 'b'}, {'b2': 'b'}], label='batch', reviewer=None, statuses=[])
pr_c = make_pr(repo_a, 'C', [{'c1': 'c', 'c2': 'c'}])
pr_b.post_comment('hansen p=0', 'reviewer')
env['runbot_merge.project']._check_progress()
# should have batched pr_a and pr_b despite neither being reviewed or
# approved
p_a, p_b = to_pr(env, pr_a), to_pr(env, pr_b)
p_c = to_pr(env, pr_c)
assert p_a.batch_id and p_b.batch_id and p_a.batch_id == p_b.batch_id,\
"a and b should have been recognised as co-dependent"
assert not p_c.staging_id