[IMP] runbot_merge: commit message edition abstraction

Prepares for more complex edition operations on the forwardbot side

* split out the pseudo-headers from the message body
* don't separate the co-authored-by headers from the others, seems
  unnecessary, we just need to ensure they're at the end so github
  doesn't miss them (/it)
This commit is contained in:
Xavier Morel 2019-08-23 16:16:30 +02:00 committed by xmo-odoo
parent c5582ce154
commit 1b1aa637fe
3 changed files with 98 additions and 30 deletions

View File

@ -1,6 +1,6 @@
import base64 import base64
import collections
import datetime import datetime
import io
import json import json
import logging import logging
import os import os
@ -11,9 +11,11 @@ import time
from itertools import takewhile from itertools import takewhile
import requests import requests
from werkzeug.datastructures import Headers
from odoo import api, fields, models, tools from odoo import api, fields, models, tools
from odoo.exceptions import ValidationError from odoo.exceptions import ValidationError
from odoo.tools import OrderedSet
from .. import github, exceptions, controllers, utils from .. import github, exceptions, controllers, utils
@ -989,34 +991,27 @@ class PullRequests(models.Model):
if commit: if commit:
self.env.cr.commit() self.env.cr.commit()
def _parse_commit_message(self, message):
""" Parses a commit message to split out the pseudo-headers (which
should be at the end) from the body, and serialises back with a
predefined pseudo-headers ordering.
"""
return Message.from_message(message)
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/) # handle co-authored commits (https://help.github.com/articles/creating-a-commit-with-multiple-authors/)
original = message.splitlines() m = self._parse_commit_message(message)
lines = [] pattern = r'( |{repository})#{pr.number}\b'.format(
coauthors = []
for line in original:
if line.startswith('Co-authored-by:'):
# remove all empty lines before C-A-B
coauthors.append(line)
while lines and not lines[-1]:
lines.pop()
continue
lines.append(line.strip())
m = re.search(r'( |{repository})#{pr.number}\b'.format(
pr=self, pr=self,
repository=self.repository.name.replace('/', '\\/') repository=self.repository.name.replace('/', '\\/')
), message) )
if not m: if not re.search(pattern, m.body):
lines.extend(['', 'closes {pr.repository.name}#{pr.number}'.format(pr=self)]) m.body += '\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: if self.reviewed_by:
lines.extend(['', '']) m.headers.add('signed-off-by', self.reviewed_by.formatted_email)
lines.extend(coauthors)
return '\n'.join(lines) return str(m)
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]
@ -1684,3 +1679,64 @@ def parse_refs_smart(read):
break # empty list (no refs) break # empty list (no refs)
m = refline.match(line) m = refline.match(line)
yield m[1].decode(), m[2].decode() yield m[1].decode(), m[2].decode()
HEADER = re.compile('^([A-Za-z-]+): (.*)$')
class Message:
@classmethod
def from_message(cls, msg):
in_headers = True
headers = []
body = []
for line in reversed(msg.splitlines()):
if not line:
if not in_headers and body and body[-1]:
body.append(line)
continue
h = HEADER.match(line)
if h:
# c-a-b = special case from an existing test, not sure if actually useful?
if in_headers or h.group(1).lower() == 'co-authored-by':
headers.append(h.groups())
continue
body.append(line)
in_headers = False
return cls('\n'.join(reversed(body)), Headers(reversed(headers)))
def __init__(self, body, headers=None):
self.body = body
self.headers = headers or Headers()
def __setattr__(self, name, value):
# make sure stored body is always stripped
if name == 'body':
value = value and value.strip()
super().__setattr__(name, value)
def __str__(self):
if not self.headers:
return self.body + '\n'
with io.StringIO(self.body) as msg:
msg.write(self.body)
msg.write('\n\n')
# https://git.wiki.kernel.org/index.php/CommitMessageConventions
# seems to mostly use capitalised names (rather than title-cased)
keys = list(OrderedSet(k.capitalize() for k in self.headers.keys()))
# c-a-b must be at the very end otherwise github doesn't see it
keys.sort(key=lambda k: k == 'Co-authored-by')
for k in keys:
for v in self.headers.getlist(k):
msg.write(k)
msg.write(': ')
msg.write(v)
msg.write('\n')
return msg.getvalue()
def sub(self, pattern, repl, *, flags):
""" Performs in-place replacements on the body
"""
self.body = re.sub(pattern, repl, self.body, flags=flags)

View File

@ -733,7 +733,7 @@ class Commit(object):
__slots__ = ['tree', 'message', 'author', 'committer', 'parents', 'statuses'] __slots__ = ['tree', 'message', 'author', 'committer', 'parents', 'statuses']
def __init__(self, tree, message, author, committer, parents): def __init__(self, tree, message, author, committer, parents):
self.tree = tree self.tree = tree
self.message = message self.message = message.strip()
self.author = Author.from_(author) or Author('', '', '') self.author = Author.from_(author) or Author('', '', '')
self.committer = Author.from_(committer) or self.author self.committer = Author.from_(committer) or self.author
self.parents = parents self.parents = parents

View File

@ -226,7 +226,12 @@ class TestCommitMessage:
""" """
c1 = repo.make_commit(None, 'first!', None, tree={'f': 'm1'}) c1 = repo.make_commit(None, 'first!', None, tree={'f': 'm1'})
repo.make_ref('heads/master', c1) repo.make_ref('heads/master', c1)
c2 = repo.make_commit(c1, 'simple commit message\n\n\nCo-authored-by: Bob <bob@example.com>\n\nFixes a thing', None, tree={'f': 'm2'}) c2 = repo.make_commit(c1, '''simple commit message
Co-authored-by: Bob <bob@example.com>
Fixes a thing''', None, tree={'f': 'm2'})
prx = repo.make_pr('title', 'body', target='master', ctid=c2, user='user') 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', 'ci/runbot')
@ -240,10 +245,17 @@ class TestCommitMessage:
run_crons(env) run_crons(env)
master = repo.commit('heads/master') master = repo.commit('heads/master')
assert master.message == "simple commit message\n\nFixes a thing\n\ncloses {repo.name}#1"\ assert master.message == """simple commit message
"\n\nSigned-off-by: {reviewer.formatted_email}"\
"\n\n\nCo-authored-by: Bob <bob@example.com>"\ Fixes a thing
.format(repo=repo, reviewer=get_partner(env, users['reviewer']))
closes {repo.name}#1
Signed-off-by: {reviewer.formatted_email}
Co-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):