[ADD] runbot_merge: sign off commits by reviewer

closes #50
closes #54
This commit is contained in:
Christophe Simonis 2018-11-21 18:43:05 +01:00 committed by Xavier Morel
parent 41ce858c93
commit 19ffcdd4a2
8 changed files with 205 additions and 40 deletions

View File

@ -39,6 +39,11 @@ class GH(object):
r.raise_for_status() r.raise_for_status()
return r return r
def user(self, username):
r = self._session.get("{}/users/{}".format(self._url, username))
r.raise_for_status()
return r.json()
def head(self, branch): def head(self, branch):
d = self('get', 'git/refs/heads/{}'.format(branch)).json() d = self('get', 'git/refs/heads/{}'.format(branch)).json()

View File

@ -415,6 +415,7 @@ class PullRequests(models.Model):
], default=False) ], default=False)
method_warned = fields.Boolean(default=False) method_warned = fields.Boolean(default=False)
reviewed_by = fields.Many2one('res.partner')
delegates = fields.Many2many('res.partner', help="Delegate reviewers, not intrinsically reviewers but can review this PR") delegates = fields.Many2many('res.partner', help="Delegate reviewers, not intrinsically reviewers but can review this PR")
priority = fields.Selection([ priority = fields.Selection([
(0, 'Urgent'), (0, 'Urgent'),
@ -583,6 +584,7 @@ class PullRequests(models.Model):
newstate = RPLUS.get(self.state) newstate = RPLUS.get(self.state)
if newstate: if newstate:
self.state = newstate self.state = newstate
self.reviewed_by = author
ok = True ok = True
else: else:
msg = "This PR is already reviewed, reviewing it again is useless." msg = "This PR is already reviewed, reviewing it again is useless."
@ -807,13 +809,33 @@ class PullRequests(models.Model):
self.env.cr.commit() self.env.cr.commit()
def _build_merge_message(self, message): def _build_merge_message(self, message):
# handle co-authored commits (https://help.github.com/articles/creating-a-commit-with-multiple-authors/)
lines = message.splitlines()
coauthors = []
for idx, line in enumerate(reversed(lines)):
if line.startswith('Co-authored-by:'):
coauthors.append(line)
continue
if not line.strip():
continue
if idx:
del lines[-idx:]
break
m = re.search(r'( |{repository})#{pr.number}\b'.format( m = re.search(r'( |{repository})#{pr.number}\b'.format(
pr=self, pr=self,
repository=self.repository.name.replace('/', '\\/') repository=self.repository.name.replace('/', '\\/')
), message) ), message)
if m: if not m:
return message lines.extend(['', 'closes {pr.repository.name}#{pr.number}'.format(pr=self)])
return message + '\n\ncloses {pr.repository.name}#{pr.number}'.format(pr=self) if self.reviewed_by:
lines.extend(['', 'Signed-off-by: {}'.format(self.reviewed_by.formatted_email)])
if coauthors:
lines.extend(['', ''])
lines.extend(reversed(coauthors))
return '\n'.join(lines)
def _stage(self, gh, target): def _stage(self, gh, target):
# nb: pr_commits is oldest to newest so pr.head is pr_commits[-1] # nb: pr_commits is oldest to newest so pr.head is pr_commits[-1]
@ -825,6 +847,12 @@ class PullRequests(models.Model):
assert commits < 250, "merging PRs of 250+ commits is not supported (https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request)" assert commits < 250, "merging PRs of 250+ commits is not supported (https://developer.github.com/v3/pulls/#list-commits-on-a-pull-request)"
pr_commits = gh.commits(self.number) pr_commits = gh.commits(self.number)
if self.reviewed_by and self.reviewed_by.name == self.reviewed_by.github_login:
# XXX: find other trigger(s) to sync github name?
gh_name = gh.user(self.reviewed_by.github_login)['name']
if gh_name:
self.reviewed_by.name = gh_name
# NOTE: lost merge v merge/copy distinction (head being # NOTE: lost merge v merge/copy distinction (head being
# a merge commit reused instead of being re-merged) # a merge commit reused instead of being re-merged)
return method, getattr(self, '_stage_' + method.replace('-', '_'))(gh, target, pr_commits) return method, getattr(self, '_stage_' + method.replace('-', '_'))(gh, target, pr_commits)

View File

@ -1,4 +1,5 @@
from odoo import fields, models, tools from email.utils import parseaddr, formataddr
from odoo import fields, models, tools, api
class Partner(models.Model): class Partner(models.Model):
_inherit = 'res.partner' _inherit = 'res.partner'
@ -7,9 +8,22 @@ class Partner(models.Model):
reviewer = fields.Boolean(default=False, help="Can review PRs (maybe m2m to repos/branches?)") reviewer = fields.Boolean(default=False, help="Can review PRs (maybe m2m to repos/branches?)")
self_reviewer = fields.Boolean(default=False, help="Can review own PRs (independent from reviewer)") self_reviewer = fields.Boolean(default=False, help="Can review own PRs (independent from reviewer)")
delegate_reviewer = fields.Many2many('runbot_merge.pull_requests') delegate_reviewer = fields.Many2many('runbot_merge.pull_requests')
formatted_email = fields.Char(compute='_rfc5322_formatted')
def _auto_init(self): def _auto_init(self):
res = super(Partner, self)._auto_init() res = super(Partner, self)._auto_init()
tools.create_unique_index( tools.create_unique_index(
self._cr, 'runbot_merge_unique_gh_login', self._table, ['github_login']) self._cr, 'runbot_merge_unique_gh_login', self._table, ['github_login'])
return res return res
@api.multi
def _rfc5322_formatted(self):
# format partner's email according to RFC5322 section 3.4
for partner in self:
if partner.email:
email = parseaddr(partner.email)[1]
elif partner.github_login:
email = '%s@users.noreply.github.com' % partner.github_login
else:
email = ''
partner.formatted_email = formataddr((partner.name, email))

View File

@ -16,14 +16,18 @@ from werkzeug.urls import url_parse, url_encode
from . import git from . import git
API_PATTERN = re.compile( REPOS_API_PATTERN = re.compile(
r'https://api.github.com/repos/(?P<repo>\w+/\w+)/(?P<path>.+)' r'https://api.github.com/repos/(?P<repo>\w+/\w+)/(?P<path>.+)'
) )
USERS_API_PATTERN = re.compile(
r"https://api.github.com/users/(?P<user>\w+)"
)
class APIResponse(responses.BaseResponse): class APIResponse(responses.BaseResponse):
def __init__(self, sim): def __init__(self, sim, url):
super(APIResponse, self).__init__( super(APIResponse, self).__init__(
method=None, method=None,
url=API_PATTERN url=url
) )
self.sim = sim self.sim = sim
self.content_type = 'application/json' self.content_type = 'application/json'
@ -35,7 +39,7 @@ class APIResponse(responses.BaseResponse):
def get_response(self, request): def get_response(self, request):
m = self.url.match(request.url) m = self.url.match(request.url)
r = self.sim.repos[m.group('repo')].api(m.group('path'), request) r = self.dispatch(request, m)
if isinstance(r, responses.HTTPResponse): if isinstance(r, responses.HTTPResponse):
return r return r
@ -52,6 +56,21 @@ class APIResponse(responses.BaseResponse):
headers=headers, headers=headers,
preload_content=False, ) preload_content=False, )
class ReposAPIResponse(APIResponse):
def __init__(self, sim):
super().__init__(sim, REPOS_API_PATTERN)
def dispatch(self, request, match):
return self.sim.repos[match.group('repo')].api(match.group('path'), request)
class UsersAPIResponse(APIResponse):
def __init__(self, sim):
super().__init__(sim, url=USERS_API_PATTERN)
def dispatch(self, request, match):
return self.sim._read_user(request, match.group('user'))
class Github(object): class Github(object):
""" Github simulator """ Github simulator
@ -74,12 +93,22 @@ class Github(object):
def __enter__(self): def __enter__(self):
# otherwise swallows errors from within the test # otherwise swallows errors from within the test
self._requests = responses.RequestsMock(assert_all_requests_are_fired=False).__enter__() self._requests = responses.RequestsMock(assert_all_requests_are_fired=False).__enter__()
self._requests.add(APIResponse(self)) self._requests.add(ReposAPIResponse(self))
self._requests.add(UsersAPIResponse(self))
return self return self
def __exit__(self, *args): def __exit__(self, *args):
return self._requests.__exit__(*args) return self._requests.__exit__(*args)
def _read_user(self, _, user):
return (200, {
'id': id(user),
'type': 'User',
'login': user,
'name': user.capitalize(),
})
class Repo(object): class Repo(object):
def __init__(self, name): def __init__(self, name):
self.name = name self.name = name

View File

@ -77,6 +77,7 @@ def users(env):
'name': "Reviewer", 'name': "Reviewer",
'github_login': 'reviewer', 'github_login': 'reviewer',
'reviewer': True, 'reviewer': True,
'email': "reviewer@example.com",
}) })
env['res.partner'].create({ env['res.partner'].create({
'name': "Self Reviewer", 'name': "Self Reviewer",

View File

@ -10,13 +10,13 @@ from lxml import html
import odoo import odoo
from test_utils import re_matches, run_crons from test_utils import re_matches, run_crons, get_partner
@pytest.fixture @pytest.fixture
def repo(make_repo): def repo(make_repo):
return make_repo('repo') return make_repo('repo')
def test_trivial_flow(env, repo, page): def test_trivial_flow(env, repo, page, users):
# create base branch # create base branch
m = repo.make_commit(None, "initial", None, tree={'a': 'some content'}) m = repo.make_commit(None, "initial", None, tree={'a': 'some content'})
repo.make_ref('heads/master', m) repo.make_ref('heads/master', m)
@ -91,7 +91,9 @@ def test_trivial_flow(env, repo, page):
'a': b'some other content', 'a': b'some other content',
'b': b'a second file', 'b': b'a second file',
} }
assert master.message == "gibberish\n\nblahblah\n\ncloses {repo.name}#1".format(repo=repo) assert master.message == "gibberish\n\nblahblah\n\ncloses {repo.name}#1"\
"\n\nSigned-off-by: {reviewer.formatted_email}"\
.format(repo=repo, reviewer=get_partner(env, users['reviewer']))
class TestCommitMessage: class TestCommitMessage:
def test_commit_simple(self, env, repo, users): def test_commit_simple(self, env, repo, users):
@ -113,7 +115,9 @@ class TestCommitMessage:
env['runbot_merge.project']._check_progress() env['runbot_merge.project']._check_progress()
master = repo.commit('heads/master') master = repo.commit('heads/master')
assert master.message == "simple commit message\n\ncloses {repo.name}#1".format(repo=repo) assert master.message == "simple commit message\n\ncloses {repo.name}#1"\
"\n\nSigned-off-by: {reviewer.formatted_email}"\
.format(repo=repo, reviewer=get_partner(env, users['reviewer']))
def test_commit_existing(self, env, repo, users): def test_commit_existing(self, env, repo, users):
""" verify do not duplicate 'closes' instruction """ verify do not duplicate 'closes' instruction
@ -135,7 +139,9 @@ class TestCommitMessage:
master = repo.commit('heads/master') master = repo.commit('heads/master')
# closes #1 is already present, should not modify message # closes #1 is already present, should not modify message
assert master.message == "simple commit message that closes #1" assert master.message == "simple commit message that closes #1"\
"\n\nSigned-off-by: {reviewer.formatted_email}"\
.format(reviewer=get_partner(env, users['reviewer']))
def test_commit_other(self, env, repo, users): def test_commit_other(self, env, repo, users):
""" verify do not duplicate 'closes' instruction """ verify do not duplicate 'closes' instruction
@ -157,7 +163,9 @@ class TestCommitMessage:
master = repo.commit('heads/master') master = repo.commit('heads/master')
# closes on another repositoy, should modify the commit message # closes on another repositoy, should modify the commit message
assert master.message == "simple commit message that closes odoo/enterprise#1\n\ncloses {repo.name}#1".format(repo=repo) assert master.message == "simple commit message that closes odoo/enterprise#1\n\ncloses {repo.name}#1"\
"\n\nSigned-off-by: {reviewer.formatted_email}"\
.format(repo=repo, reviewer=get_partner(env, users['reviewer']))
def test_commit_wrong_number(self, env, repo, users): def test_commit_wrong_number(self, env, repo, users):
""" verify do not match on a wrong number """ verify do not match on a wrong number
@ -179,8 +187,57 @@ class TestCommitMessage:
master = repo.commit('heads/master') master = repo.commit('heads/master')
# closes on another repositoy, should modify the commit message # closes on another repositoy, should modify the commit message
assert master.message == "simple commit message that closes #11\n\ncloses {repo.name}#1".format(repo=repo) assert master.message == "simple commit message that closes #11\n\ncloses {repo.name}#1"\
"\n\nSigned-off-by: {reviewer.formatted_email}"\
.format(repo=repo, reviewer=get_partner(env, users['reviewer']))
def test_commit_delegate(self, env, repo, users):
""" verify 'signed-off-by ...' is correctly added in the commit message for delegated review
"""
c1 = repo.make_commit(None, 'first!', None, tree={'f': 'm1'})
repo.make_ref('heads/master', c1)
c2 = repo.make_commit(c1, 'simple commit message', None, tree={'f': 'm2'})
prx = repo.make_pr('title', 'body', target='master', ctid=c2, user='user')
repo.post_status(prx.head, 'success', 'ci/runbot')
repo.post_status(prx.head, 'success', 'legal/cla')
prx.post_comment('hansen delegate=%s' % users['other'], "reviewer")
prx.post_comment('hansen r+', user='other')
env['runbot_merge.project']._check_progress()
repo.post_status('heads/staging.master', 'success', 'ci/runbot')
repo.post_status('heads/staging.master', 'success', 'legal/cla')
env['runbot_merge.project']._check_progress()
master = repo.commit('heads/master')
assert master.message == "simple commit message\n\ncloses {repo.name}#1"\
"\n\nSigned-off-by: {reviewer.formatted_email}"\
.format(repo=repo, reviewer=get_partner(env, users['other']))
def test_commit_coauthored(self, env, repo, users):
""" verify 'closes ...' and 'Signed-off-by' are added before co-authored-by tags.
"""
c1 = repo.make_commit(None, 'first!', None, tree={'f': 'm1'})
repo.make_ref('heads/master', c1)
c2 = repo.make_commit(c1, 'simple commit message\n\n\nCo-authored-by: Bob <bob@example.com>', None, tree={'f': 'm2'})
prx = repo.make_pr('title', 'body', target='master', ctid=c2, user='user')
repo.post_status(prx.head, 'success', 'ci/runbot')
repo.post_status(prx.head, 'success', 'legal/cla')
prx.post_comment('hansen r+', "reviewer")
env['runbot_merge.project']._check_progress()
repo.post_status('heads/staging.master', 'success', 'ci/runbot')
repo.post_status('heads/staging.master', 'success', 'legal/cla')
env['runbot_merge.project']._check_progress()
master = repo.commit('heads/master')
assert master.message == "simple commit message\n\ncloses {repo.name}#1"\
"\n\nSigned-off-by: {reviewer.formatted_email}"\
"\n\n\nCo-authored-by: Bob <bob@example.com>"\
.format(repo=repo, reviewer=get_partner(env, users['reviewer']))
class TestWebhookSecurity: class TestWebhookSecurity:
def test_no_secret(self, env, project, repo): def test_no_secret(self, env, project, repo):
@ -487,11 +544,12 @@ def test_ff_failure_batch(env, repo, users):
c['commit']['message'] c['commit']['message']
for c in repo.log('heads/master') for c in repo.log('heads/master')
} }
reviewer = get_partner(env, users["reviewer"]).formatted_email
assert messages == { assert messages == {
'initial', 'NO!', 'initial', 'NO!',
'a1', 'a2', 'A\n\ncloses {}#{}'.format(repo.name, A.number), 'a1', 'a2', 'A\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, A.number, reviewer),
'b1', 'b2', 'B\n\ncloses {}#{}'.format(repo.name, B.number), 'b1', 'b2', 'B\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, B.number, reviewer),
'c1', 'c2', 'C\n\ncloses {}#{}'.format(repo.name, C.number), 'c1', 'c2', 'C\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, C.number, reviewer),
} }
def test_edit(env, repo): def test_edit(env, repo):
@ -974,7 +1032,7 @@ class TestMergeMethod:
(users['user'], "Merge method set to rebase and fast-forward"), (users['user'], "Merge method set to rebase and fast-forward"),
] ]
def test_pr_rebase_merge(self, repo, env): def test_pr_rebase_merge(self, repo, env, users):
""" test result on rebase-merge """ test result on rebase-merge
left: PR left: PR
@ -1026,8 +1084,9 @@ class TestMergeMethod:
# then compare to the dag version of the right graph # then compare to the dag version of the right graph
nm2 = node('M2', node('M1', node('M0'))) nm2 = node('M2', node('M1', node('M0')))
nb1 = node('B1', node('B0', nm2)) nb1 = node('B1', node('B0', nm2))
reviewer = get_partner(env, users["reviewer"]).formatted_email
merge_head = ( merge_head = (
'title\n\nbody\n\ncloses {}#{}'.format(repo.name, prx.number), 'title\n\nbody\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, prx.number, reviewer),
frozenset([nm2, nb1]) frozenset([nm2, nb1])
) )
expected = (re_matches('^force rebuild'), frozenset([merge_head])) expected = (re_matches('^force rebuild'), frozenset([merge_head]))
@ -1048,7 +1107,7 @@ class TestMergeMethod:
final_tree = repo.read_tree(repo.commit('heads/master')) final_tree = repo.read_tree(repo.commit('heads/master'))
assert final_tree == {'m': b'2', 'b': b'1'}, "sanity check of final tree" assert final_tree == {'m': b'2', 'b': b'1'}, "sanity check of final tree"
def test_pr_rebase_ff(self, repo, env): def test_pr_rebase_ff(self, repo, env, users):
""" test result on rebase-merge """ test result on rebase-merge
left: PR left: PR
@ -1090,7 +1149,9 @@ class TestMergeMethod:
staging = log_to_node(repo.log('heads/staging.master')) staging = log_to_node(repo.log('heads/staging.master'))
# then compare to the dag version of the right graph # then compare to the dag version of the right graph
nm2 = node('M2', node('M1', node('M0'))) nm2 = node('M2', node('M1', node('M0')))
nb1 = node('B1\n\ncloses {}#{}'.format(repo.name, prx.number), node('B0', nm2)) reviewer = get_partner(env, users["reviewer"]).formatted_email
nb1 = node('B1\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, prx.number, reviewer),
node('B0', nm2))
expected = node(re_matches('^force rebuild'), nb1) expected = node(re_matches('^force rebuild'), nb1)
assert staging == expected assert staging == expected
@ -1113,7 +1174,7 @@ class TestMergeMethod:
def test_pr_contains_merges(self, repo, env): def test_pr_contains_merges(self, repo, env):
pass pass
def test_pr_force_merge_single_commit(self, repo, env): def test_pr_force_merge_single_commit(self, repo, env, users):
""" should be possible to flag a PR as regular-merged, regardless of """ should be possible to flag a PR as regular-merged, regardless of
its commits count its commits count
@ -1146,10 +1207,12 @@ class TestMergeMethod:
m = node('M') m = node('M')
c0 = node('C0', m) c0 = node('C0', m)
expected = node('gibberish\n\nblahblah\n\ncloses {}#{}'.format(repo.name, prx.number), m, c0) reviewer = get_partner(env, users["reviewer"]).formatted_email
expected = node('gibberish\n\nblahblah\n\ncloses {}#{}'
'\n\nSigned-off-by: {}'.format(repo.name, prx.number, reviewer), m, c0)
assert log_to_node(repo.log('heads/master')), expected assert log_to_node(repo.log('heads/master')), expected
def test_unrebase_emptymessage(self, repo, env): def test_unrebase_emptymessage(self, repo, env, users):
""" When merging between master branches (e.g. forward port), the PR """ When merging between master branches (e.g. forward port), the PR
may have only a title may have only a title
""" """
@ -1175,7 +1238,9 @@ class TestMergeMethod:
m = node('M') m = node('M')
c0 = node('C0', m) c0 = node('C0', m)
expected = node('gibberish\n\ncloses {}#{}'.format(repo.name, prx.number), m, c0) reviewer = get_partner(env, users["reviewer"]).formatted_email
expected = node('gibberish\n\ncloses {}#{}'
'\n\nSigned-off-by: {}'.format(repo.name, prx.number, reviewer), m, c0)
assert log_to_node(repo.log('heads/master')), expected assert log_to_node(repo.log('heads/master')), expected
def test_pr_mergehead(self, repo, env): def test_pr_mergehead(self, repo, env):
@ -1214,7 +1279,7 @@ class TestMergeMethod:
expected = node('C1', node('C0', m1), node('M2', m1)) expected = node('C1', node('C0', m1), node('M2', m1))
assert log_to_node(repo.log('heads/master')), expected assert log_to_node(repo.log('heads/master')), expected
def test_pr_mergehead_nonmember(self, repo, env): def test_pr_mergehead_nonmember(self, repo, env, users):
""" if the head of the PR is a merge commit but none of the parents is """ if the head of the PR is a merge commit but none of the parents is
in the target, merge normally in the target, merge normally
@ -1253,8 +1318,9 @@ class TestMergeMethod:
assert repo.read_tree(master) == {'a': b'1', 'b': b'2', 'bb': b'bb'} assert repo.read_tree(master) == {'a': b'1', 'b': b'2', 'bb': b'bb'}
m1 = node('M1') m1 = node('M1')
reviewer = get_partner(env, users["reviewer"]).formatted_email
expected = node( expected = node(
'T\n\nTT\n\ncloses {}#{}'.format(repo.name, prx.number), 'T\n\nTT\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, prx.number, reviewer),
node('M2', m1), node('M2', m1),
node('C1', node('C0', m1), node('B0', m1)) node('C1', node('C0', m1), node('B0', m1))
) )
@ -1556,7 +1622,7 @@ class TestBatching(object):
('number', '=', number), ('number', '=', number),
]) ])
def test_staging_batch(self, env, repo): def test_staging_batch(self, env, repo, users):
""" If multiple PRs are ready for the same target at the same point, """ If multiple PRs are ready for the same target at the same point,
they should be staged together they should be staged together
""" """
@ -1576,20 +1642,21 @@ class TestBatching(object):
log = list(repo.log('heads/staging.master')) log = list(repo.log('heads/staging.master'))
staging = log_to_node(log) staging = log_to_node(log)
reviewer = get_partner(env, users["reviewer"]).formatted_email
p1 = node( p1 = node(
'title PR1\n\nbody PR1\n\ncloses {}#{}'.format(repo.name, pr1.number), 'title PR1\n\nbody PR1\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, pr1.number, reviewer),
node('initial'), node('initial'),
node('commit_PR1_01', node('commit_PR1_00', node('initial'))) node('commit_PR1_01', node('commit_PR1_00', node('initial')))
) )
p2 = node( p2 = node(
'title PR2\n\nbody PR2\n\ncloses {}#{}'.format(repo.name, pr2.number), 'title PR2\n\nbody PR2\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, pr2.number, reviewer),
p1, p1,
node('commit_PR2_01', node('commit_PR2_00', p1)) node('commit_PR2_01', node('commit_PR2_00', p1))
) )
expected = (re_matches('^force rebuild'), frozenset([p2])) expected = (re_matches('^force rebuild'), frozenset([p2]))
assert staging == expected assert staging == expected
def test_staging_batch_norebase(self, env, repo): def test_staging_batch_norebase(self, env, repo, users):
""" If multiple PRs are ready for the same target at the same point, """ If multiple PRs are ready for the same target at the same point,
they should be staged together they should be staged together
""" """
@ -1614,21 +1681,22 @@ class TestBatching(object):
log = list(repo.log('heads/staging.master')) log = list(repo.log('heads/staging.master'))
staging = log_to_node(log) staging = log_to_node(log)
reviewer = get_partner(env, users["reviewer"]).formatted_email
p1 = node( p1 = node(
'title PR1\n\nbody PR1\n\ncloses {}#{}'.format(repo.name, pr1.number), 'title PR1\n\nbody PR1\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, pr1.number, reviewer),
node('initial'), node('initial'),
node('commit_PR1_01', node('commit_PR1_00', node('initial'))) node('commit_PR1_01', node('commit_PR1_00', node('initial')))
) )
p2 = node( p2 = node(
'title PR2\n\nbody PR2\n\ncloses {}#{}'.format(repo.name, pr2.number), 'title PR2\n\nbody PR2\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, pr2.number, reviewer),
p1, p1,
node('commit_PR2_01', node('commit_PR2_00', node('initial'))) node('commit_PR2_01', node('commit_PR2_00', node('initial')))
) )
expected = (re_matches('^force rebuild'), frozenset([p2])) expected = (re_matches('^force rebuild'), frozenset([p2]))
assert staging == expected assert staging == expected
def test_staging_batch_squash(self, env, repo): def test_staging_batch_squash(self, env, repo, users):
""" If multiple PRs are ready for the same target at the same point, """ If multiple PRs are ready for the same target at the same point,
they should be staged together they should be staged together
""" """
@ -1649,10 +1717,11 @@ class TestBatching(object):
log = list(repo.log('heads/staging.master')) log = list(repo.log('heads/staging.master'))
staging = log_to_node(log) staging = log_to_node(log)
reviewer = get_partner(env, users["reviewer"]).formatted_email
expected = node( expected = node(
re_matches('^force rebuild'), re_matches('^force rebuild'),
node('commit_PR2_00\n\ncloses {}#{}'.format(repo.name, pr2.number), node('commit_PR2_00\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, pr2.number, reviewer),
node('commit_PR1_00\n\ncloses {}#{}'.format(repo.name, pr1.number), node('commit_PR1_00\n\ncloses {}#{}\n\nSigned-off-by: {}'.format(repo.name, pr1.number, reviewer),
node('initial')))) node('initial'))))
assert staging == expected assert staging == expected
@ -2307,3 +2376,18 @@ def log_to_node(log):
todo.append(c) todo.append(c)
return nodes[log[0]['sha']] return nodes[log[0]['sha']]
class TestEmailFormatting:
def test_simple(self, env):
p1 = env['res.partner'].create({
'name': 'Bob',
'email': 'bob@example.com',
})
assert p1.formatted_email == 'Bob <bob@example.com>'
def test_noemail(self, env):
p1 = env['res.partner'].create({
'name': 'Shultz',
'github_login': 'Osmose99',
})
assert p1.formatted_email == 'Shultz <Osmose99@users.noreply.github.com>'

View File

@ -9,7 +9,7 @@ import json
import pytest import pytest
from test_utils import re_matches, run_crons from test_utils import re_matches, run_crons, get_partner
@pytest.fixture @pytest.fixture
def repo_a(make_repo): def repo_a(make_repo):
@ -201,13 +201,14 @@ def test_merge_fail(env, project, repo_a, repo_b, users):
(users['user'], re_matches('^Unable to stage PR')), (users['user'], re_matches('^Unable to stage PR')),
] ]
other = to_pr(env, pr1a) other = to_pr(env, pr1a)
reviewer = get_partner(env, users["reviewer"]).formatted_email
assert not other.staging_id assert not other.staging_id
assert [ assert [
c['commit']['message'] c['commit']['message']
for c in repo_a.log('heads/staging.master') for c in repo_a.log('heads/staging.master')
] == [ ] == [
re_matches('^force rebuild'), re_matches('^force rebuild'),
'commit_A2_00\n\ncloses %s#2' % repo_a.name, 'commit_A2_00\n\ncloses %s#2\n\nSigned-off-by: %s' % (repo_a.name, reviewer),
'initial' 'initial'
], "dummy commit + squash-merged PR commit + root commit" ], "dummy commit + squash-merged PR commit + root commit"

View File

@ -17,3 +17,6 @@ def run_crons(env):
env['runbot_merge.project']._check_progress() env['runbot_merge.project']._check_progress()
env['runbot_merge.pull_requests']._check_linked_prs_statuses() env['runbot_merge.pull_requests']._check_linked_prs_statuses()
env['runbot_merge.project']._send_feedback() env['runbot_merge.project']._send_feedback()
def get_partner(env, gh_login):
return env['res.partner'].search([('github_login', '=', gh_login)])