mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[ADD] runbot_merge: tag PR to signify state changes
This commit is contained in:
parent
7033952913
commit
39a0d723af
@ -50,6 +50,19 @@ 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_):
|
||||||
|
to_add, to_remove = to_ - from_, from_ - to_
|
||||||
|
for t in to_remove:
|
||||||
|
r = self('DELETE', 'issues/{}/labels/{}'.format(pr, t), check=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
if to_add:
|
||||||
|
self('POST', 'issues/{}/labels'.format(pr), json=list(to_add))
|
||||||
|
|
||||||
def fast_forward(self, branch, sha):
|
def fast_forward(self, branch, sha):
|
||||||
try:
|
try:
|
||||||
self('patch', 'git/refs/heads/{}'.format(branch), json={'sha': sha})
|
self('patch', 'git/refs/heads/{}'.format(branch), json={'sha': sha})
|
||||||
|
@ -177,8 +177,53 @@ class Project(models.Model):
|
|||||||
# create staging branch from tmp
|
# create staging branch from tmp
|
||||||
for r, it in meta.items():
|
for r, it in meta.items():
|
||||||
it['gh'].set_ref('staging.{}'.format(branch.name), it['head'])
|
it['gh'].set_ref('staging.{}'.format(branch.name), it['head'])
|
||||||
|
|
||||||
|
# creating the staging doesn't trigger a write on the prs
|
||||||
|
# and thus the ->staging taggings, so do that by hand
|
||||||
|
Tagging = self.env['runbot_merge.pull_requests.tagging']
|
||||||
|
for pr in st.mapped('batch_ids.prs'):
|
||||||
|
Tagging.create({
|
||||||
|
'pull_request': pr.number,
|
||||||
|
'repository': pr.repository.id,
|
||||||
|
'state_from': pr._tagstate,
|
||||||
|
'state_to': 'staged',
|
||||||
|
})
|
||||||
|
|
||||||
logger.info("Created staging %s", st)
|
logger.info("Created staging %s", st)
|
||||||
|
|
||||||
|
Repos = self.env['runbot_merge.repository']
|
||||||
|
ghs = {}
|
||||||
|
self.env.cr.execute("""
|
||||||
|
SELECT
|
||||||
|
t.repository as repo_id,
|
||||||
|
t.pull_request as pr_number,
|
||||||
|
array_agg(t.id) as ids,
|
||||||
|
(array_agg(t.state_from ORDER BY t.id))[1] as state_from,
|
||||||
|
(array_agg(t.state_to ORDER BY t.id DESC))[1] as state_to
|
||||||
|
FROM runbot_merge_pull_requests_tagging t
|
||||||
|
GROUP BY t.repository, t.pull_request
|
||||||
|
""")
|
||||||
|
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)
|
||||||
|
if not gh:
|
||||||
|
gh = ghs[repo] = repo.github()
|
||||||
|
|
||||||
|
try:
|
||||||
|
gh.change_tags(pr, from_tags, 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,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
to_remove.extend(ids)
|
||||||
|
self.env['runbot_merge.pull_requests.tagging'].browse(to_remove).unlink()
|
||||||
|
|
||||||
def is_timed_out(self, staging):
|
def is_timed_out(self, staging):
|
||||||
return fields.Datetime.from_string(staging.staged_at) + datetime.timedelta(minutes=self.ci_timeout) < datetime.datetime.now()
|
return fields.Datetime.from_string(staging.staged_at) + datetime.timedelta(minutes=self.ci_timeout) < datetime.datetime.now()
|
||||||
|
|
||||||
@ -505,6 +550,99 @@ class PullRequests(models.Model):
|
|||||||
self._cr, 'runbot_merge_unique_pr_per_target', self._table, ['number', 'target', 'repository'])
|
self._cr, 'runbot_merge_unique_pr_per_target', self._table, ['number', 'target', 'repository'])
|
||||||
return res
|
return res
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _tagstate(self):
|
||||||
|
if self.state == 'ready' and self.staging_id.heads:
|
||||||
|
return 'staged'
|
||||||
|
return self.state
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def create(self, vals):
|
||||||
|
pr = super().create(vals)
|
||||||
|
self.env['runbot_merge.pull_requests.tagging'].create({
|
||||||
|
'pull_request': pr.number,
|
||||||
|
'repository': pr.repository.id,
|
||||||
|
'state_from': False,
|
||||||
|
'state_to': pr._tagstate,
|
||||||
|
})
|
||||||
|
return pr
|
||||||
|
|
||||||
|
@api.multi
|
||||||
|
def write(self, vals):
|
||||||
|
oldstate = { pr: pr._tagstate for pr in self }
|
||||||
|
w = super().write(vals)
|
||||||
|
for pr in self:
|
||||||
|
before, after = oldstate[pr], pr._tagstate
|
||||||
|
if after != before:
|
||||||
|
self.env['runbot_merge.pull_requests.tagging'].create({
|
||||||
|
'pull_request': pr.number,
|
||||||
|
'repository': pr.repository.id,
|
||||||
|
'state_from': oldstate[pr],
|
||||||
|
'state_to': pr._tagstate,
|
||||||
|
})
|
||||||
|
return w
|
||||||
|
|
||||||
|
@api.multi
|
||||||
|
def unlink(self):
|
||||||
|
for pr in self:
|
||||||
|
self.env['runbot_merge.pull_requests.tagging'].create({
|
||||||
|
'pull_request': pr.number,
|
||||||
|
'repository': pr.repository.id,
|
||||||
|
'state_from': pr._tagstate,
|
||||||
|
'state_to': False,
|
||||||
|
})
|
||||||
|
return super().unlink()
|
||||||
|
|
||||||
|
|
||||||
|
_TAGS = {
|
||||||
|
False: set(),
|
||||||
|
'opened': {'seen 🙂'},
|
||||||
|
}
|
||||||
|
_TAGS['validated'] = _TAGS['opened'] | {'CI 🤖'}
|
||||||
|
_TAGS['approved'] = _TAGS['opened'] | {'r+ 👌'}
|
||||||
|
_TAGS['ready'] = _TAGS['validated'] | _TAGS['approved']
|
||||||
|
_TAGS['staged'] = _TAGS['ready'] | {'merging 👷'}
|
||||||
|
_TAGS['merged'] = _TAGS['ready'] | {'merged 🎉'}
|
||||||
|
_TAGS['error'] = _TAGS['opened'] | {'error 🙅'}
|
||||||
|
_TAGS['closed'] = _TAGS['opened'] | {'closed 💔'}
|
||||||
|
|
||||||
|
class Tagging(models.Model):
|
||||||
|
"""
|
||||||
|
Queue of tag changes to make on PRs.
|
||||||
|
|
||||||
|
Several PR state changes are driven by webhooks, webhooks should return
|
||||||
|
quickly, performing calls to the Github API would *probably* get in the
|
||||||
|
way of that. Instead, queue tagging changes into this table whose
|
||||||
|
execution can be cron-driven.
|
||||||
|
"""
|
||||||
|
_name = 'runbot_merge.pull_requests.tagging'
|
||||||
|
|
||||||
|
repository = fields.Many2one('pull_request.repository', required=True)
|
||||||
|
# store the PR number (not id) as we need a Tagging for PR objects
|
||||||
|
# being deleted (retargeted to non-managed branches)
|
||||||
|
pull_request = fields.Integer()
|
||||||
|
|
||||||
|
state_from = fields.Selection([
|
||||||
|
('opened', 'Opened'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
('validated', 'Validated'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('ready', 'Ready'),
|
||||||
|
('staged', 'Staged'),
|
||||||
|
('merged', 'Merged'),
|
||||||
|
('error', 'Error'),
|
||||||
|
])
|
||||||
|
state_to = fields.Selection([
|
||||||
|
('opened', 'Opened'),
|
||||||
|
('closed', 'Closed'),
|
||||||
|
('validated', 'Validated'),
|
||||||
|
('approved', 'Approved'),
|
||||||
|
('ready', 'Ready'),
|
||||||
|
('staged', 'Staged'),
|
||||||
|
('merged', 'Merged'),
|
||||||
|
('error', 'Error'),
|
||||||
|
])
|
||||||
|
|
||||||
class Commit(models.Model):
|
class Commit(models.Model):
|
||||||
"""Represents a commit onto which statuses might be posted,
|
"""Represents a commit onto which statuses might be posted,
|
||||||
independent of everything else as commits can be created by
|
independent of everything else as commits can be created by
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import collections
|
import collections
|
||||||
import io
|
import io
|
||||||
import itertools
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import responses
|
import responses
|
||||||
|
import werkzeug.urls
|
||||||
import werkzeug.test
|
import werkzeug.test
|
||||||
import werkzeug.wrappers
|
import werkzeug.wrappers
|
||||||
|
|
||||||
@ -333,6 +333,31 @@ class Repo(object):
|
|||||||
|
|
||||||
return (200, {})
|
return (200, {})
|
||||||
|
|
||||||
|
def _add_labels(self, r, number):
|
||||||
|
try:
|
||||||
|
pr = self.issues[int(number)]
|
||||||
|
except KeyError:
|
||||||
|
return (404, None)
|
||||||
|
|
||||||
|
pr.labels.update(json.loads(r.body))
|
||||||
|
|
||||||
|
return (200, {})
|
||||||
|
|
||||||
|
def _remove_label(self, r, number, label):
|
||||||
|
print('remove_label', number, label)
|
||||||
|
try:
|
||||||
|
pr = self.issues[int(number)]
|
||||||
|
except KeyError:
|
||||||
|
return (404, None)
|
||||||
|
|
||||||
|
print(pr, pr.labels)
|
||||||
|
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'):
|
||||||
@ -394,6 +419,9 @@ class Repo(object):
|
|||||||
('POST', r'merges', _do_merge),
|
('POST', r'merges', _do_merge),
|
||||||
|
|
||||||
('PATCH', r'pulls/(?P<number>\d+)', _edit_pr),
|
('PATCH', r'pulls/(?P<number>\d+)', _edit_pr),
|
||||||
|
|
||||||
|
('POST', r'issues/(?P<number>\d+)/labels', _add_labels),
|
||||||
|
('DELETE', r'issues/(?P<number>\d+)/labels/(?P<label>.+)', _remove_label),
|
||||||
]
|
]
|
||||||
|
|
||||||
class Issue(object):
|
class Issue(object):
|
||||||
@ -403,6 +431,7 @@ class Issue(object):
|
|||||||
self._body = body
|
self._body = body
|
||||||
self.number = max(repo.issues or [0]) + 1
|
self.number = max(repo.issues or [0]) + 1
|
||||||
self.comments = []
|
self.comments = []
|
||||||
|
self.labels = set()
|
||||||
repo.issues[self.number] = self
|
repo.issues[self.number] = self
|
||||||
|
|
||||||
def post_comment(self, body, user):
|
def post_comment(self, body, user):
|
||||||
|
@ -52,18 +52,24 @@ def test_trivial_flow(env, repo):
|
|||||||
('number', '=', pr1.number),
|
('number', '=', pr1.number),
|
||||||
])
|
])
|
||||||
assert pr.state == 'opened'
|
assert pr.state == 'opened'
|
||||||
|
env['runbot_merge.project']._check_progress()
|
||||||
|
assert pr1.labels == {'seen 🙂'}
|
||||||
# nothing happened
|
# nothing happened
|
||||||
|
|
||||||
repo.post_status(c1, 'success', 'legal/cla')
|
repo.post_status(c1, 'success', 'legal/cla')
|
||||||
repo.post_status(c1, 'success', 'ci/runbot')
|
repo.post_status(c1, 'success', 'ci/runbot')
|
||||||
assert pr.state == 'validated'
|
assert pr.state == 'validated'
|
||||||
|
env['runbot_merge.project']._check_progress()
|
||||||
|
assert pr1.labels == {'seen 🙂', 'CI 🤖'}
|
||||||
|
|
||||||
pr1.post_comment('hansen r+', 'reviewer')
|
pr1.post_comment('hansen r+', 'reviewer')
|
||||||
assert pr.state == 'ready'
|
assert pr.state == 'ready'
|
||||||
|
|
||||||
|
# can't check labels here as running the cron will stage it
|
||||||
|
|
||||||
env['runbot_merge.project']._check_progress()
|
env['runbot_merge.project']._check_progress()
|
||||||
print(pr.read()[0])
|
|
||||||
assert pr.staging_id
|
assert pr.staging_id
|
||||||
|
assert pr1.labels == {'seen 🙂', 'CI 🤖', 'r+ 👌', 'merging 👷'}
|
||||||
|
|
||||||
# get head of staging branch
|
# get head of staging branch
|
||||||
staging_head = repo.commit('heads/staging.master')
|
staging_head = repo.commit('heads/staging.master')
|
||||||
@ -72,6 +78,7 @@ def test_trivial_flow(env, repo):
|
|||||||
|
|
||||||
env['runbot_merge.project']._check_progress()
|
env['runbot_merge.project']._check_progress()
|
||||||
assert pr.state == 'merged'
|
assert pr.state == 'merged'
|
||||||
|
assert pr1.labels == {'seen 🙂', 'CI 🤖', 'r+ 👌', 'merged 🎉'}
|
||||||
|
|
||||||
master = repo.commit('heads/master')
|
master = repo.commit('heads/master')
|
||||||
assert master.parents == [m, pr1.head],\
|
assert master.parents == [m, pr1.head],\
|
||||||
@ -109,24 +116,25 @@ def test_staging_conflict(env, repo):
|
|||||||
repo.post_status(c3, 'success', 'ci/runbot')
|
repo.post_status(c3, 'success', 'ci/runbot')
|
||||||
pr2.post_comment('hansen r+', "reviewer")
|
pr2.post_comment('hansen r+', "reviewer")
|
||||||
env['runbot_merge.project']._check_progress()
|
env['runbot_merge.project']._check_progress()
|
||||||
pr2 = env['runbot_merge.pull_requests'].search([
|
p_2 = env['runbot_merge.pull_requests'].search([
|
||||||
('repository.name', '=', 'odoo/odoo'),
|
('repository.name', '=', 'odoo/odoo'),
|
||||||
('number', '=', 2)
|
('number', '=', 2)
|
||||||
])
|
])
|
||||||
assert pr2.state == 'ready', "PR2 should not have been staged since there is a pending staging for master"
|
assert p_2.state == 'ready', "PR2 should not have been staged since there is a pending staging for master"
|
||||||
|
assert pr2.labels == {'seen 🙂', 'CI 🤖', 'r+ 👌'}
|
||||||
|
|
||||||
staging_head = repo.commit('heads/staging.master')
|
staging_head = repo.commit('heads/staging.master')
|
||||||
repo.post_status(staging_head.id, 'success', 'ci/runbot')
|
repo.post_status(staging_head.id, 'success', 'ci/runbot')
|
||||||
repo.post_status(staging_head.id, 'success', 'legal/cla')
|
repo.post_status(staging_head.id, 'success', 'legal/cla')
|
||||||
env['runbot_merge.project']._check_progress()
|
env['runbot_merge.project']._check_progress()
|
||||||
assert pr1.state == 'merged'
|
assert pr1.state == 'merged'
|
||||||
assert pr2.staging_id
|
assert p_2.staging_id
|
||||||
|
|
||||||
staging_head = repo.commit('heads/staging.master')
|
staging_head = repo.commit('heads/staging.master')
|
||||||
repo.post_status(staging_head.id, 'success', 'ci/runbot')
|
repo.post_status(staging_head.id, 'success', 'ci/runbot')
|
||||||
repo.post_status(staging_head.id, 'success', 'legal/cla')
|
repo.post_status(staging_head.id, 'success', 'legal/cla')
|
||||||
env['runbot_merge.project']._check_progress()
|
env['runbot_merge.project']._check_progress()
|
||||||
assert pr2.state == 'merged'
|
assert p_2.state == 'merged'
|
||||||
|
|
||||||
def test_staging_concurrent(env, repo):
|
def test_staging_concurrent(env, repo):
|
||||||
""" test staging to different targets, should be picked up together """
|
""" test staging to different targets, should be picked up together """
|
||||||
@ -184,6 +192,7 @@ def test_staging_merge_fail(env, repo):
|
|||||||
('number', '=', prx.number)
|
('number', '=', prx.number)
|
||||||
])
|
])
|
||||||
assert pr1.state == 'error'
|
assert pr1.state == 'error'
|
||||||
|
assert prx.labels == {'seen 🙂', 'error 🙅'}
|
||||||
assert prx.comments == [
|
assert prx.comments == [
|
||||||
('reviewer', 'hansen r+'),
|
('reviewer', 'hansen r+'),
|
||||||
('<insert current user here>', 'Unable to stage PR (merge conflict)')
|
('<insert current user here>', 'Unable to stage PR (merge conflict)')
|
||||||
@ -312,6 +321,8 @@ def test_edit(env, repo):
|
|||||||
# FIXME: should a PR retargeted to an unmanaged branch really be deleted?
|
# FIXME: should a PR retargeted to an unmanaged branch really be deleted?
|
||||||
prx.base = '2.0'
|
prx.base = '2.0'
|
||||||
assert not pr.exists()
|
assert not pr.exists()
|
||||||
|
env['runbot_merge.project']._check_progress()
|
||||||
|
assert prx.labels == set()
|
||||||
|
|
||||||
prx.base = '1.0'
|
prx.base = '1.0'
|
||||||
assert env['runbot_merge.pull_requests'].search([
|
assert env['runbot_merge.pull_requests'].search([
|
||||||
|
Loading…
Reference in New Issue
Block a user