mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[IMP] *: add draft support to mergebot, kinda
* Remove the forwardport creating PRs in draft, that was mostly to avoid codeowners triggering but we've removed the github one and hand-rolled it, so not a concern anymore. * Prevent merging `draft` PRs, the mergebot rejects approval on draft PRs and insults people. TBD (maybe): try to create *conflicting* forward-port PRs in draft so it's clearer they need to be *fixed*? Issue of not being able to do that on all private repositories remains so~~ Fixes #500
This commit is contained in:
parent
4b12d88b3e
commit
82174ae66e
37
conftest.py
37
conftest.py
@ -688,7 +688,7 @@ class Repo:
|
|||||||
)).raise_for_status()
|
)).raise_for_status()
|
||||||
return PR(self, number)
|
return PR(self, number)
|
||||||
|
|
||||||
def make_pr(self, *, title=None, body=None, target, head, token=None):
|
def make_pr(self, *, title=None, body=None, target, head, draft=False, token=None):
|
||||||
assert self.hook
|
assert self.hook
|
||||||
self.hook = 2
|
self.hook = 2
|
||||||
|
|
||||||
@ -717,6 +717,7 @@ class Repo:
|
|||||||
'body': body,
|
'body': body,
|
||||||
'head': head,
|
'head': head,
|
||||||
'base': target,
|
'base': target,
|
||||||
|
'draft': draft,
|
||||||
},
|
},
|
||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
@ -774,6 +775,22 @@ class Comment(tuple):
|
|||||||
def __getitem__(self, item):
|
def __getitem__(self, item):
|
||||||
return self._c[item]
|
return self._c[item]
|
||||||
|
|
||||||
|
|
||||||
|
PR_SET_READY = '''
|
||||||
|
mutation setReady($pid: ID!) {
|
||||||
|
markPullRequestReadyForReview(input: { pullRequestId: $pid}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
|
||||||
|
PR_SET_DRAFT = '''
|
||||||
|
mutation setDraft($pid: ID!) {
|
||||||
|
convertPullRequestToDraft(input: { pullRequestId: $pid }) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
class PR:
|
class PR:
|
||||||
def __init__(self, repo, number):
|
def __init__(self, repo, number):
|
||||||
self.repo = repo
|
self.repo = repo
|
||||||
@ -796,6 +813,22 @@ class PR:
|
|||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
base = base.setter(lambda self, v: self._set_prop('base', v))
|
base = base.setter(lambda self, v: self._set_prop('base', v))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def draft(self):
|
||||||
|
return self._pr['draft']
|
||||||
|
@draft.setter
|
||||||
|
def draft(self, v):
|
||||||
|
assert self.repo.hook
|
||||||
|
# apparently it's not possible to update the draft flag via the v3 API,
|
||||||
|
# only the V4...
|
||||||
|
r = self.repo._session.post('https://api.github.com/graphql', json={
|
||||||
|
'query': PR_SET_DRAFT if v else PR_SET_READY,
|
||||||
|
'variables': {'pid': self._pr['node_id']}
|
||||||
|
})
|
||||||
|
assert r.ok, r.text
|
||||||
|
out = r.json()
|
||||||
|
assert 'errors' not in out, out['errors']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def head(self):
|
def head(self):
|
||||||
return self._pr['head']['sha']
|
return self._pr['head']['sha']
|
||||||
@ -863,7 +896,7 @@ class PR:
|
|||||||
r = self.repo._session.patch('https://api.github.com/repos/{}/pulls/{}'.format(self.repo.name, self.number), json={
|
r = self.repo._session.patch('https://api.github.com/repos/{}/pulls/{}'.format(self.repo.name, self.number), json={
|
||||||
prop: value
|
prop: value
|
||||||
}, headers=headers)
|
}, headers=headers)
|
||||||
assert 200 <= r.status_code < 300, r.json()
|
assert r.ok, r.text
|
||||||
|
|
||||||
def open(self, token=None):
|
def open(self, token=None):
|
||||||
self._set_prop('state', 'open', token=token)
|
self._set_prop('state', 'open', token=token)
|
||||||
|
@ -123,7 +123,7 @@ class UpdateQueue(models.Model, Queue):
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
# QUESTION: update PR to draft if there are conflicts?
|
|
||||||
conflicts, working_copy = previous._create_fp_branch(
|
conflicts, working_copy = previous._create_fp_branch(
|
||||||
child.target, child.refname, s)
|
child.target, child.refname, s)
|
||||||
if conflicts:
|
if conflicts:
|
||||||
|
@ -662,16 +662,9 @@ class PullRequests(models.Model):
|
|||||||
url = 'https://api.github.com/repos/{}/pulls'.format(pr.repository.name)
|
url = 'https://api.github.com/repos/{}/pulls'.format(pr.repository.name)
|
||||||
pr_data = {
|
pr_data = {
|
||||||
'base': target.name, 'head': '%s:%s' % (owner, new_branch),
|
'base': target.name, 'head': '%s:%s' % (owner, new_branch),
|
||||||
'title': title, 'body': body, 'draft': True
|
'title': title, 'body': body
|
||||||
}
|
}
|
||||||
r = gh.post(url, json=pr_data)
|
r = gh.post(url, json=pr_data)
|
||||||
if r.status_code == 422:
|
|
||||||
# assume this is a private repo which doesn't support draft PRs
|
|
||||||
# (github error response doesn't provide any machine
|
|
||||||
# information, only a human-readable message) so retry without
|
|
||||||
del pr_data['draft']
|
|
||||||
r = gh.post(url, json=pr_data)
|
|
||||||
|
|
||||||
if not r.ok:
|
if not r.ok:
|
||||||
_logger.warning("Failed to create forward-port PR for %s, deleting branches", pr.display_name)
|
_logger.warning("Failed to create forward-port PR for %s, deleting branches", pr.display_name)
|
||||||
# delete all the branches this should automatically close the
|
# delete all the branches this should automatically close the
|
||||||
|
@ -92,7 +92,7 @@ More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
|
|||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
env.run_crons()
|
env.run_crons()
|
||||||
assert pra_id | prb_id | prc_id == env['runbot_merge.pull_requests'].search([], order='number'),\
|
assert pra_id | prb_id | prc_id == env['runbot_merge.pull_requests'].search([], order='number'),\
|
||||||
"CI passing should not have resumed the FP process on a conflicting / draft PR"
|
"CI passing should not have resumed the FP process on a conflicting PR"
|
||||||
|
|
||||||
# fix the PR, should behave as if this were a normal PR
|
# fix the PR, should behave as if this were a normal PR
|
||||||
prc = prod.get_pr(prc_id.number)
|
prc = prod.get_pr(prc_id.number)
|
||||||
@ -199,7 +199,7 @@ def test_conflict_deleted(env, config, make_repo):
|
|||||||
time.sleep(5)
|
time.sleep(5)
|
||||||
env.run_crons()
|
env.run_crons()
|
||||||
assert pr0 | pr1 == env['runbot_merge.pull_requests'].search([], order='number'),\
|
assert pr0 | pr1 == env['runbot_merge.pull_requests'].search([], order='number'),\
|
||||||
"CI passing should not have resumed the FP process on a conflicting / draft PR"
|
"CI passing should not have resumed the FP process on a conflicting PR"
|
||||||
|
|
||||||
# fix the PR, should behave as if this were a normal PR
|
# fix the PR, should behave as if this were a normal PR
|
||||||
get_pr = prod.get_pr(pr1.number)
|
get_pr = prod.get_pr(pr1.number)
|
||||||
|
@ -234,7 +234,7 @@ More info at https://github.com/odoo/odoo/wiki/Mergebot#forward-port
|
|||||||
|
|
||||||
def test_empty(env, config, make_repo, users):
|
def test_empty(env, config, make_repo, users):
|
||||||
""" Cherrypick of an already cherrypicked (or separately implemented)
|
""" Cherrypick of an already cherrypicked (or separately implemented)
|
||||||
commit -> create draft PR.
|
commit -> conflicting pr.
|
||||||
"""
|
"""
|
||||||
prod, other = make_basic(env, config, make_repo)
|
prod, other = make_basic(env, config, make_repo)
|
||||||
# merge change to b
|
# merge change to b
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from utils import seen, Commit
|
from utils import seen, Commit, to_pr
|
||||||
|
|
||||||
|
|
||||||
def make_basic(env, config, make_repo, *, fp_token, fp_remote):
|
def make_basic(env, config, make_repo, *, fp_token, fp_remote):
|
||||||
@ -658,3 +658,28 @@ def test_skip_ci_next(env, config, make_repo):
|
|||||||
_, _, pr2_id = env['runbot_merge.pull_requests'].search([], order='number')
|
_, _, pr2_id = env['runbot_merge.pull_requests'].search([], order='number')
|
||||||
assert pr1_id.state == 'opened'
|
assert pr1_id.state == 'opened'
|
||||||
assert pr2_id.state == 'opened'
|
assert pr2_id.state == 'opened'
|
||||||
|
|
||||||
|
def test_approve_draft(env, config, make_repo, users):
|
||||||
|
_, prod, _ = make_basic(env, config, make_repo, fp_token=True, fp_remote=True)
|
||||||
|
|
||||||
|
with prod:
|
||||||
|
prod.make_commits('a', Commit('x', tree={'x': '0'}), ref='heads/change')
|
||||||
|
pr = prod.make_pr(target='a', head='change', draft=True)
|
||||||
|
pr.post_comment('hansen r+', config['role_reviewer']['token'])
|
||||||
|
env.run_crons()
|
||||||
|
|
||||||
|
pr_id = to_pr(env, pr)
|
||||||
|
assert pr_id.state == 'opened'
|
||||||
|
assert pr.comments == [
|
||||||
|
(users['reviewer'], 'hansen r+'),
|
||||||
|
seen(env, pr, users),
|
||||||
|
(users['user'], f"I'm sorry, @{users['reviewer']}. Draft PRs can not be approved."),
|
||||||
|
]
|
||||||
|
|
||||||
|
with prod:
|
||||||
|
pr.draft = False
|
||||||
|
assert pr.draft is False
|
||||||
|
with prod:
|
||||||
|
pr.post_comment('hansen r+', config['role_reviewer']['token'])
|
||||||
|
env.run_crons()
|
||||||
|
assert pr_id.state == 'approved'
|
||||||
|
@ -189,7 +189,14 @@ def handle_pr(env, event):
|
|||||||
'head': pr['head']['sha'],
|
'head': pr['head']['sha'],
|
||||||
'squash': pr['commits'] == 1,
|
'squash': pr['commits'] == 1,
|
||||||
})
|
})
|
||||||
return 'Updated {} to {}'.format(pr_obj.id, pr_obj.head)
|
return 'Updated {} to {}'.format(pr_obj.display_name, pr_obj.head)
|
||||||
|
|
||||||
|
if event['action'] == 'ready_for_review':
|
||||||
|
pr_obj.draft = False
|
||||||
|
return f'Updated {pr_obj.display_name} to ready'
|
||||||
|
if event['action'] == 'converted_to_draft':
|
||||||
|
pr_obj.draft = True
|
||||||
|
return f'Updated {pr_obj.display_name} to draft'
|
||||||
|
|
||||||
# don't marked merged PRs as closed (!!!)
|
# don't marked merged PRs as closed (!!!)
|
||||||
if event['action'] == 'closed' and pr_obj.state != 'merged':
|
if event['action'] == 'closed' and pr_obj.state != 'merged':
|
||||||
@ -201,7 +208,7 @@ def handle_pr(env, event):
|
|||||||
pr_obj.display_name,
|
pr_obj.display_name,
|
||||||
oldstate,
|
oldstate,
|
||||||
)
|
)
|
||||||
return 'Closed {}'.format(pr_obj.id)
|
return 'Closed {}'.format(pr_obj.display_name)
|
||||||
else:
|
else:
|
||||||
_logger.warning(
|
_logger.warning(
|
||||||
'%s tried to close %s (state=%s)',
|
'%s tried to close %s (state=%s)',
|
||||||
@ -227,7 +234,7 @@ def handle_pr(env, event):
|
|||||||
'squash': pr['commits'] == 1,
|
'squash': pr['commits'] == 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
return 'Reopened {}'.format(pr_obj.id)
|
return 'Reopened {}'.format(pr_obj.display_name)
|
||||||
|
|
||||||
_logger.info("Ignoring event %s on PR %s", event['action'], pr['number'])
|
_logger.info("Ignoring event %s on PR %s", event['action'], pr['number'])
|
||||||
return "Not handling {} yet".format(event['action'])
|
return "Not handling {} yet".format(event['action'])
|
||||||
|
@ -688,6 +688,7 @@ class PullRequests(models.Model):
|
|||||||
"cross-repository branch-matching"
|
"cross-repository branch-matching"
|
||||||
)
|
)
|
||||||
message = fields.Text(required=True)
|
message = fields.Text(required=True)
|
||||||
|
draft = fields.Boolean(default=False, required=True)
|
||||||
squash = fields.Boolean(default=False)
|
squash = fields.Boolean(default=False)
|
||||||
merge_method = fields.Selection([
|
merge_method = fields.Selection([
|
||||||
('merge', "merge directly, using the PR as merge commit message"),
|
('merge', "merge directly, using the PR as merge commit message"),
|
||||||
@ -999,7 +1000,9 @@ class PullRequests(models.Model):
|
|||||||
'number': self.number,
|
'number': self.number,
|
||||||
})
|
})
|
||||||
elif command == 'review':
|
elif command == 'review':
|
||||||
if param and is_reviewer:
|
if self.draft:
|
||||||
|
msg = "Draft PRs can not be approved."
|
||||||
|
elif param and is_reviewer:
|
||||||
oldstate = self.state
|
oldstate = self.state
|
||||||
newstate = RPLUS.get(self.state)
|
newstate = RPLUS.get(self.state)
|
||||||
if newstate:
|
if newstate:
|
||||||
@ -1250,6 +1253,7 @@ class PullRequests(models.Model):
|
|||||||
'head': description['head']['sha'],
|
'head': description['head']['sha'],
|
||||||
'squash': description['commits'] == 1,
|
'squash': description['commits'] == 1,
|
||||||
'message': message,
|
'message': message,
|
||||||
|
'draft': description['draft'],
|
||||||
})
|
})
|
||||||
|
|
||||||
def write(self, vals):
|
def write(self, vals):
|
||||||
|
@ -902,6 +902,7 @@ class TestSubstitutions:
|
|||||||
},
|
},
|
||||||
'pull_request': {
|
'pull_request': {
|
||||||
'state': 'open',
|
'state': 'open',
|
||||||
|
'draft': False,
|
||||||
'user': {'login': 'bob'},
|
'user': {'login': 'bob'},
|
||||||
'base': {
|
'base': {
|
||||||
'repo': {'full_name': r.name},
|
'repo': {'full_name': r.name},
|
||||||
|
Loading…
Reference in New Issue
Block a user