[FIX] runbot_merge: handling of staging cancellation

Move staging cancellation to the batch, remove its (complicated)
handling from the PRs.

This loses some precision in the cancellation message, but that could
likely be recovered (in part) by adding more precise checks &
diagnostic extractions in the compute.
This commit is contained in:
Xavier Morel 2024-02-09 08:58:19 +01:00
parent ad1d590d9c
commit a6a37f8896
5 changed files with 81 additions and 30 deletions

View File

@ -1175,6 +1175,9 @@ class Model:
def __len__(self): def __len__(self):
return len(self._ids) return len(self._ids)
def __hash__(self):
return hash((self._model, frozenset(self._ids)))
def __eq__(self, other): def __eq__(self, other):
if not isinstance(other, Model): if not isinstance(other, Model):
return NotImplemented return NotImplemented

View File

@ -92,4 +92,11 @@ class Batch(models.Model):
p.display_name for p in unready p.display_name for p in unready
) )
else: else:
if batch.blocked and batch.cancel_staging:
batch.target.active_staging_id.cancel(
'unstaged by %s on %s (%s)',
self.env.user.login,
batch,
', '.join(batch.prs.mapped('display_name')),
)
batch.blocked = False batch.blocked = False

View File

@ -281,6 +281,8 @@ class Parser:
return Priority.ALONE return Priority.ALONE
def parse_cancel(self) -> CancelStaging: def parse_cancel(self) -> CancelStaging:
self.assert_next('=')
self.assert_next('staging')
return CancelStaging() return CancelStaging()
def parse_skipchecks(self) -> SkipChecks: def parse_skipchecks(self) -> SkipChecks:

View File

@ -700,7 +700,7 @@ class PullRequests(models.Model):
else: else:
msg = self._approve(author, login) msg = self._approve(author, login)
case commands.Reject() if is_author: case commands.Reject() if is_author:
if cancellers := self.batch_id.cancel_staging: if self.batch_id.cancel_staging:
self.batch_id.cancel_staging = False self.batch_id.cancel_staging = False
if self.batch_id.skipchecks or self.reviewed_by: if self.batch_id.skipchecks or self.reviewed_by:
if self.error: if self.error:
@ -756,14 +756,11 @@ class PullRequests(models.Model):
p.reviewed_by = author p.reviewed_by = author
case commands.CancelStaging() if is_admin: case commands.CancelStaging() if is_admin:
self.batch_id.cancel_staging = True self.batch_id.cancel_staging = True
# FIXME: remove this when skipchecks properly affects state, if not self.batch_id.blocked:
# maybe: staging cancellation should then only occur self.target.active_staging_id.cancel(
# when a cancel_staging PR transitions to ready, or "Unstaged by %s on %s",
# a ready PR is flagged as cancelling staging author.github_login, self.display_name,
self.target.active_staging_id.cancel( )
"Unstaged by %s on %s",
author.github_login, self.display_name,
)
case commands.Override(statuses): case commands.Override(statuses):
for status in statuses: for status in statuses:
overridable = author.override_rights\ overridable = author.override_rights\
@ -1081,12 +1078,6 @@ class PullRequests(models.Model):
def write(self, vals): def write(self, vals):
if vals.get('squash'): if vals.get('squash'):
vals['merge_method'] = False vals['merge_method'] = False
fields = []
canceler = vals.get('cancel_staging') or any(p.cancel_staging for p in self)
if canceler:
fields.append('state')
fields.append('skipchecks')
prev = {pr.id: {field: pr[field] for field in fields} for pr in self}
# when explicitly marking a PR as ready # when explicitly marking a PR as ready
if vals.get('state') == 'ready': if vals.get('state') == 'ready':
@ -1144,21 +1135,6 @@ class PullRequests(models.Model):
if newhead: if newhead:
c = self.env['runbot_merge.commit'].search([('sha', '=', newhead)]) c = self.env['runbot_merge.commit'].search([('sha', '=', newhead)])
self._validate(c.statuses or '{}') self._validate(c.statuses or '{}')
if canceler:
for pr in self:
if not pr.cancel_staging:
continue
old = prev[pr.id]
def ready(pr):
return pr['state'] == 'ready'\
or (pr['skipchecks'] and pr['state'] != 'error')
if ready(pr) and not ready(old):
if old['state'] == 'error': # error -> ready gets a bespoke message
pr.target.active_staging_id.cancel(f"retrying {pr.display_name}")
else:
pr.target.active_staging_id.cancel(f"{pr.display_name} became ready")
return w return w
def _check_linked_prs_statuses(self, commit=False): def _check_linked_prs_statuses(self, commit=False):

View File

@ -9,6 +9,7 @@ import functools
import operator import operator
import time import time
import xmlrpc.client import xmlrpc.client
from itertools import repeat
import pytest import pytest
import requests import requests
@ -1468,3 +1469,65 @@ def test_freeze_conflict(env, project, repo_a, repo_b, repo_c, users, config):
with pytest.raises(AssertionError) as e: with pytest.raises(AssertionError) as e:
repo_b.get_ref('heads/1.1') repo_b.get_ref('heads/1.1')
assert e.value.args[0].startswith("Not Found") assert e.value.args[0].startswith("Not Found")
def test_cancel_staging(env, project, repo_a, repo_b, users, config):
"""If a batch is flagged as staging cancelling (from any PR), the staging
should get cancelled if and when the batch transitions to unblocked
"""
with repo_a, repo_b:
make_branch(repo_a, 'master', 'initial', {'a': '1'})
make_branch(repo_b, 'master', 'initial', {'b': '1'})
pr_a = make_pr(repo_a, 'batch', [{'a': '2'}], user=config['role_user']['token'], statuses=[], reviewer=None)
pr_b = make_pr(repo_b, 'batch', [{'b': '2'}], user=config['role_user']['token'], statuses=[], reviewer=None)
pr_lone = make_pr(
repo_a,
"C",
[{'c': '1'}],
user=config['role_user']['token'],
reviewer=config['role_reviewer']['token'],
)
env.run_crons()
a_id, b_id, lone_id = map(to_pr, repeat(env), [pr_a, pr_b, pr_lone])
assert lone_id.staging_id
st = lone_id.staging_id
with repo_a:
pr_a.post_comment("hansen cancel=staging", config['role_reviewer']['token'])
assert a_id.state == 'opened'
assert a_id.cancel_staging
assert b_id.cancel_staging
assert lone_id.staging_id == st
with repo_a:
pr_a.post_comment('hansen r+', config['role_reviewer']['token'])
assert a_id.state == 'approved'
assert lone_id.staging_id == st
with repo_a:
repo_a.post_status(a_id.head, 'success', 'ci/runbot')
repo_a.post_status(a_id.head, 'success', 'legal/cla')
env.run_crons()
assert a_id.state == 'ready'
assert lone_id.staging_id == st
assert b_id.state == 'opened'
with repo_b:
pr_b.post_comment('hansen r+', config['role_reviewer']['token'])
assert b_id.state == 'approved'
assert lone_id.staging_id == st
with repo_b:
repo_b.post_status(b_id.head, 'success', 'ci/runbot')
repo_b.post_status(b_id.head, 'success', 'legal/cla')
assert b_id.state == 'approved'
assert lone_id.staging_id == st
env.run_crons()
assert b_id.state == 'ready'
# should have cancelled the staging, picked a and b, and re-staged the
# entire thing
assert lone_id.staging_id != st
assert len({
lone_id.staging_id.id,
a_id.staging_id.id,
b_id.staging_id.id,
}) == 1