mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[FIX] runbot_merge: resync tags on stage change
Before this change mergebot assumes github's tags are in sync with its "previous" state, but because tags update was highly non-atomic (one call per removal plus one for additions) and state can further change between a failure and an update retry (especially as the labels endpoint fails *a lot*), it's possible for set tags (in github) to be completely desync'd from the mergebot state, leading to very misleading on-pr indications. This first fetches the current tagstate from github (to not lose non- mergebot tags) then (hopefully atomically) resets all tags tags based on the current mergebot state. This should avoid desyncs, and eventually resync PRs (if they change state). Fixes #170
This commit is contained in:
parent
2a18ef4195
commit
429257d013
@ -18,6 +18,7 @@ class GH(object):
|
|||||||
self._repo = repo
|
self._repo = repo
|
||||||
session = self._session = requests.Session()
|
session = self._session = requests.Session()
|
||||||
session.headers['Authorization'] = 'token {}'.format(token)
|
session.headers['Authorization'] = 'token {}'.format(token)
|
||||||
|
session.headers['Accept'] = 'application/vnd.github.symmetra-preview+json'
|
||||||
|
|
||||||
def __call__(self, method, path, params=None, json=None, check=True):
|
def __call__(self, method, path, params=None, json=None, check=True):
|
||||||
"""
|
"""
|
||||||
@ -97,19 +98,17 @@ class GH(object):
|
|||||||
self.comment(pr, message)
|
self.comment(pr, message)
|
||||||
self('PATCH', 'pulls/{}'.format(pr), json={'state': 'closed'})
|
self('PATCH', 'pulls/{}'.format(pr), json={'state': 'closed'})
|
||||||
|
|
||||||
def change_tags(self, pr, from_, to_):
|
def change_tags(self, pr, to_):
|
||||||
to_add, to_remove = to_ - from_, from_ - to_
|
labels_endpoint = 'issues/{}/labels'.format(pr)
|
||||||
for t in to_remove:
|
from .models.pull_requests import _TAGS
|
||||||
r = self('DELETE', 'issues/{}/labels/{}'.format(pr, t), check=False)
|
mergebot_tags = set.union(*_TAGS.values())
|
||||||
# successful deletion or attempt to delete a tag which isn't there
|
tags_before = {label['name'] for label in self('GET', labels_endpoint).json()}
|
||||||
# is fine, otherwise trigger an error
|
# remove all mergebot tags from the PR, then add just the ones which should be set
|
||||||
if r.status_code not in (200, 404):
|
tags_after = (tags_before - mergebot_tags) | to_
|
||||||
r.raise_for_status()
|
# replace labels entirely
|
||||||
|
self('PUT', labels_endpoint, json={'labels': list(tags_after)})
|
||||||
|
|
||||||
if to_add:
|
_logger.debug('change_tags(%s, %s, from=%s, to=%s)', self._repo, pr, tags_before, tags_after)
|
||||||
self('POST', 'issues/{}/labels'.format(pr), json=list(to_add))
|
|
||||||
|
|
||||||
_logger.debug('change_tags(%s, %s, remove=%s, add=%s)', self._repo, pr, to_remove, to_add)
|
|
||||||
|
|
||||||
def fast_forward(self, branch, sha):
|
def fast_forward(self, branch, sha):
|
||||||
try:
|
try:
|
||||||
|
@ -98,7 +98,6 @@ class Project(models.Model):
|
|||||||
to_remove = []
|
to_remove = []
|
||||||
for repo_id, pr, ids, from_, to_ in self.env.cr.fetchall():
|
for repo_id, pr, ids, from_, to_ in self.env.cr.fetchall():
|
||||||
repo = Repos.browse(repo_id)
|
repo = Repos.browse(repo_id)
|
||||||
from_tags = _TAGS[from_ or False]
|
|
||||||
to_tags = _TAGS[to_ or False]
|
to_tags = _TAGS[to_ or False]
|
||||||
|
|
||||||
gh = ghs.get(repo)
|
gh = ghs.get(repo)
|
||||||
@ -106,11 +105,11 @@ class Project(models.Model):
|
|||||||
gh = ghs[repo] = repo.github()
|
gh = ghs[repo] = repo.github()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
gh.change_tags(pr, from_tags, to_tags)
|
gh.change_tags(pr, to_tags)
|
||||||
except Exception:
|
except Exception:
|
||||||
_logger.exception(
|
_logger.exception(
|
||||||
"Error while trying to change the tags of %s:%s from %s to %s",
|
"Error while trying to change the tags of %s:%s from %s to %s",
|
||||||
repo.name, pr, from_tags, to_tags,
|
repo.name, pr, _TAGS[from_ or False], to_tags,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
to_remove.extend(ids)
|
to_remove.extend(ids)
|
||||||
|
@ -502,29 +502,24 @@ class Repo(object):
|
|||||||
body=body, preload_content=False,
|
body=body, preload_content=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _add_labels(self, r, number):
|
def _get_labels(self, r, number):
|
||||||
try:
|
try:
|
||||||
pr = self.issues[int(number)]
|
pr = self.issues[int(number)]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return (404, None)
|
return (404, None)
|
||||||
|
|
||||||
pr.labels.update(json.loads(r.body))
|
return (200, [{'name': label} for label in pr.labels])
|
||||||
|
|
||||||
|
def _reset_labels(self, r, number):
|
||||||
|
try:
|
||||||
|
pr = self.issues[int(number)]
|
||||||
|
except KeyError:
|
||||||
|
return (404, None)
|
||||||
|
|
||||||
|
pr.labels = set(json.loads(r.body)['labels'])
|
||||||
|
|
||||||
return (200, {})
|
return (200, {})
|
||||||
|
|
||||||
def _remove_label(self, _, number, label):
|
|
||||||
try:
|
|
||||||
pr = self.issues[int(number)]
|
|
||||||
except KeyError:
|
|
||||||
return (404, None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
pr.labels.remove(werkzeug.urls.url_unquote(label))
|
|
||||||
except KeyError:
|
|
||||||
return (404, None)
|
|
||||||
else:
|
|
||||||
return (200, {})
|
|
||||||
|
|
||||||
def _do_merge(self, r):
|
def _do_merge(self, r):
|
||||||
body = json.loads(r.body) # {base, head, commit_message}
|
body = json.loads(r.body) # {base, head, commit_message}
|
||||||
if not body.get('commit_message'):
|
if not body.get('commit_message'):
|
||||||
@ -585,8 +580,8 @@ class Repo(object):
|
|||||||
('GET', r'pulls/(?P<number>\d+)/reviews', _read_pr_reviews),
|
('GET', r'pulls/(?P<number>\d+)/reviews', _read_pr_reviews),
|
||||||
('GET', r'pulls/(?P<number>\d+)/commits', _read_pr_commits),
|
('GET', r'pulls/(?P<number>\d+)/commits', _read_pr_commits),
|
||||||
|
|
||||||
('POST', r'issues/(?P<number>\d+)/labels', _add_labels),
|
('GET', r'issues/(?P<number>\d+)/labels', _get_labels),
|
||||||
('DELETE', r'issues/(?P<number>\d+)/labels/(?P<label>.+)', _remove_label),
|
('PUT', r'issues/(?P<number>\d+)/labels', _reset_labels),
|
||||||
]
|
]
|
||||||
|
|
||||||
class Issue(object):
|
class Issue(object):
|
||||||
|
@ -635,8 +635,58 @@ ct = itertools.count()
|
|||||||
|
|
||||||
Commit = collections.namedtuple('Commit', 'id tree message author committer parents')
|
Commit = collections.namedtuple('Commit', 'id tree message author committer parents')
|
||||||
|
|
||||||
|
from odoo.tools.func import lazy_property
|
||||||
|
class LabelsProxy(collections.abc.MutableSet):
|
||||||
|
def __init__(self, pr):
|
||||||
|
self._pr = pr
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _labels(self):
|
||||||
|
pr = self._pr
|
||||||
|
r = pr._session.get('https://api.github.com/repos/{}/issues/{}/labels'.format(pr.repo.name, pr.number))
|
||||||
|
assert r.ok, r.json()
|
||||||
|
return {label['name'] for label in r.json()}
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return '<LabelsProxy %r>' % self._labels
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if isinstance(other, collections.abc.Set):
|
||||||
|
return other == self._labels
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
def __contains__(self, label):
|
||||||
|
return label in self._labels
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self._labels)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
return len(self._labels)
|
||||||
|
|
||||||
|
def add(self, label):
|
||||||
|
pr = self._pr
|
||||||
|
r = pr._session.post('https://api.github.com/repos/{}/issues/{}/labels'.format(pr.repo.name, pr.number), json={
|
||||||
|
'labels': [label]
|
||||||
|
})
|
||||||
|
assert r.ok, r.json()
|
||||||
|
|
||||||
|
def discard(self, label):
|
||||||
|
pr = self._pr
|
||||||
|
r = pr._session.delete('https://api.github.com/repos/{}/issues/{}/labels/{}'.format(pr.repo.name, pr.number, label))
|
||||||
|
# discard should do nothing if the item didn't exist in the set
|
||||||
|
assert r.ok or r.status_code == 404, r.json()
|
||||||
|
|
||||||
|
def update(self, *others):
|
||||||
|
pr = self._pr
|
||||||
|
# because of course that one is not provided by MutableMapping...
|
||||||
|
r = pr._session.post('https://api.github.com/repos/{}/issues/{}/labels'.format(pr.repo.name, pr.number), json={
|
||||||
|
'labels': list(set(itertools.chain.from_iterable(others)))
|
||||||
|
})
|
||||||
|
assert r.ok, r.json()
|
||||||
|
|
||||||
class PR:
|
class PR:
|
||||||
__slots__ = ['number', '_branch', 'repo']
|
__slots__ = ['number', '_branch', 'repo', 'labels']
|
||||||
def __init__(self, repo, branch, number):
|
def __init__(self, repo, branch, number):
|
||||||
"""
|
"""
|
||||||
:type repo: Repo
|
:type repo: Repo
|
||||||
@ -646,6 +696,7 @@ class PR:
|
|||||||
self.number = number
|
self.number = number
|
||||||
self._branch = branch
|
self._branch = branch
|
||||||
self.repo = repo
|
self.repo = repo
|
||||||
|
self.labels = LabelsProxy(self)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _session(self):
|
def _session(self):
|
||||||
@ -669,12 +720,6 @@ class PR:
|
|||||||
def state(self):
|
def state(self):
|
||||||
return self._pr['state']
|
return self._pr['state']
|
||||||
|
|
||||||
@property
|
|
||||||
def labels(self):
|
|
||||||
r = self._session.get('https://api.github.com/repos/{}/issues/{}/labels'.format(self.repo.name, self.number))
|
|
||||||
assert 200 <= r.status_code < 300, r.json()
|
|
||||||
return {label['name'] for label in r.json()}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def comments(self):
|
def comments(self):
|
||||||
r = self._session.get('https://api.github.com/repos/{}/issues/{}/comments'.format(self.repo.name, self.number))
|
r = self._session.get('https://api.github.com/repos/{}/issues/{}/comments'.format(self.repo.name, self.number))
|
||||||
|
@ -2658,3 +2658,57 @@ class TestEmailFormatting:
|
|||||||
'github_login': 'Osmose99',
|
'github_login': 'Osmose99',
|
||||||
})
|
})
|
||||||
assert p1.formatted_email == 'Shultz <Osmose99@users.noreply.github.com>'
|
assert p1.formatted_email == 'Shultz <Osmose99@users.noreply.github.com>'
|
||||||
|
|
||||||
|
class TestLabelling:
|
||||||
|
def test_desync(self, env, repo):
|
||||||
|
m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'})
|
||||||
|
repo.make_ref('heads/master', m)
|
||||||
|
|
||||||
|
c = repo.make_commit(m, 'replace file contents', None, tree={'a': 'some other content'})
|
||||||
|
pr = repo.make_pr('gibberish', 'blahblah', target='master', ctid=c, user='user')
|
||||||
|
|
||||||
|
[pr_id] = env['runbot_merge.pull_requests'].search([
|
||||||
|
('repository.name', '=', repo.name),
|
||||||
|
('number', '=', pr.number),
|
||||||
|
])
|
||||||
|
repo.post_status(c, 'success', 'legal/cla')
|
||||||
|
repo.post_status(c, 'success', 'ci/runbot')
|
||||||
|
|
||||||
|
run_crons(env)
|
||||||
|
|
||||||
|
assert pr.labels == {'seen 🙂', 'CI 🤖'}
|
||||||
|
# desync state and labels
|
||||||
|
pr.labels.remove('CI 🤖')
|
||||||
|
|
||||||
|
pr.post_comment('hansen r+', 'reviewer')
|
||||||
|
run_crons(env)
|
||||||
|
|
||||||
|
assert pr.labels == {'seen 🙂', 'CI 🤖', 'r+ 👌', 'merging 👷'},\
|
||||||
|
"labels should be resynchronised"
|
||||||
|
|
||||||
|
def test_other_tags(self, env, repo):
|
||||||
|
m = repo.make_commit(None, 'initial', None, tree={'a': 'some content'})
|
||||||
|
repo.make_ref('heads/master', m)
|
||||||
|
|
||||||
|
c = repo.make_commit(m, 'replace file contents', None, tree={'a': 'some other content'})
|
||||||
|
pr = repo.make_pr('gibberish', 'blahblah', target='master', ctid=c, user='user')
|
||||||
|
|
||||||
|
# "foreign" labels
|
||||||
|
pr.labels.update(('L1', 'L2'))
|
||||||
|
|
||||||
|
[pr_id] = env['runbot_merge.pull_requests'].search([
|
||||||
|
('repository.name', '=', repo.name),
|
||||||
|
('number', '=', pr.number),
|
||||||
|
])
|
||||||
|
repo.post_status(c, 'success', 'legal/cla')
|
||||||
|
repo.post_status(c, 'success', 'ci/runbot')
|
||||||
|
|
||||||
|
run_crons(env)
|
||||||
|
|
||||||
|
assert pr.labels == {'seen 🙂', 'CI 🤖', 'L1', 'L2'}, "should not lose foreign labels"
|
||||||
|
|
||||||
|
pr.post_comment('hansen r+', 'reviewer')
|
||||||
|
run_crons(env)
|
||||||
|
|
||||||
|
assert pr.labels == {'seen 🙂', 'CI 🤖', 'r+ 👌', 'merging 👷', 'L1', 'L2'},\
|
||||||
|
"should not lose foreign labels"
|
||||||
|
Loading…
Reference in New Issue
Block a user