From 429257d013dc5e5f2272f4be17b28832501d3488 Mon Sep 17 00:00:00 2001 From: xmo-odoo Date: Wed, 21 Aug 2019 11:17:04 +0200 Subject: [PATCH] [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 --- runbot_merge/github.py | 23 ++++----- runbot_merge/models/pull_requests.py | 5 +- runbot_merge/tests/fake_github/__init__.py | 29 +++++------ runbot_merge/tests/remote.py | 59 +++++++++++++++++++--- runbot_merge/tests/test_basic.py | 54 ++++++++++++++++++++ 5 files changed, 131 insertions(+), 39 deletions(-) diff --git a/runbot_merge/github.py b/runbot_merge/github.py index 0f53a286..147ad80a 100644 --- a/runbot_merge/github.py +++ b/runbot_merge/github.py @@ -18,6 +18,7 @@ class GH(object): self._repo = repo session = self._session = requests.Session() 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): """ @@ -97,19 +98,17 @@ class GH(object): self.comment(pr, message) self('PATCH', 'pulls/{}'.format(pr), json={'state': 'closed'}) - def change_tags(self, pr, from_, to_): - to_add, to_remove = to_ - from_, from_ - to_ - for t in to_remove: - r = self('DELETE', 'issues/{}/labels/{}'.format(pr, t), check=False) - # successful deletion or attempt to delete a tag which isn't there - # is fine, otherwise trigger an error - if r.status_code not in (200, 404): - r.raise_for_status() + def change_tags(self, pr, to_): + labels_endpoint = 'issues/{}/labels'.format(pr) + from .models.pull_requests import _TAGS + mergebot_tags = set.union(*_TAGS.values()) + tags_before = {label['name'] for label in self('GET', labels_endpoint).json()} + # remove all mergebot tags from the PR, then add just the ones which should be set + tags_after = (tags_before - mergebot_tags) | to_ + # replace labels entirely + self('PUT', labels_endpoint, json={'labels': list(tags_after)}) - if to_add: - 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) + _logger.debug('change_tags(%s, %s, from=%s, to=%s)', self._repo, pr, tags_before, tags_after) def fast_forward(self, branch, sha): try: diff --git a/runbot_merge/models/pull_requests.py b/runbot_merge/models/pull_requests.py index a6358234..709fb9a2 100644 --- a/runbot_merge/models/pull_requests.py +++ b/runbot_merge/models/pull_requests.py @@ -98,7 +98,6 @@ class Project(models.Model): to_remove = [] for repo_id, pr, ids, from_, to_ in self.env.cr.fetchall(): repo = Repos.browse(repo_id) - from_tags = _TAGS[from_ or False] to_tags = _TAGS[to_ or False] gh = ghs.get(repo) @@ -106,11 +105,11 @@ class Project(models.Model): gh = ghs[repo] = repo.github() try: - gh.change_tags(pr, from_tags, to_tags) + gh.change_tags(pr, to_tags) except Exception: _logger.exception( "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: to_remove.extend(ids) diff --git a/runbot_merge/tests/fake_github/__init__.py b/runbot_merge/tests/fake_github/__init__.py index 44153c27..b31c5660 100644 --- a/runbot_merge/tests/fake_github/__init__.py +++ b/runbot_merge/tests/fake_github/__init__.py @@ -502,29 +502,24 @@ class Repo(object): body=body, preload_content=False, ) - def _add_labels(self, r, number): + def _get_labels(self, r, number): try: pr = self.issues[int(number)] except KeyError: 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, {}) - 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): body = json.loads(r.body) # {base, head, commit_message} if not body.get('commit_message'): @@ -585,8 +580,8 @@ class Repo(object): ('GET', r'pulls/(?P\d+)/reviews', _read_pr_reviews), ('GET', r'pulls/(?P\d+)/commits', _read_pr_commits), - ('POST', r'issues/(?P\d+)/labels', _add_labels), - ('DELETE', r'issues/(?P\d+)/labels/(?P