aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.keys/openpgp/chromium.org/keescook/default1295
-rw-r--r--README.rst4
-rw-r--r--b4/__init__.py1250
-rw-r--r--b4/attest.py32
-rw-r--r--b4/command.py173
-rw-r--r--b4/ez.py1461
-rw-r--r--b4/mbox.py375
-rw-r--r--b4/pr.py157
-rw-r--r--b4/ty.py186
-rw-r--r--man/b4.5337
-rw-r--r--man/b4.5.rst211
-rw-r--r--misc/default.conf63
-rw-r--r--misc/send-receive.py545
m---------patatt0
-rw-r--r--requirements.txt9
-rw-r--r--setup.py10
-rw-r--r--shazam-merge-template.example19
-rw-r--r--tests/samples/trailers-followup-custody-ref-ordered.txt41
-rw-r--r--tests/samples/trailers-followup-custody-ref-unordered.txt41
-rw-r--r--tests/samples/trailers-followup-custody.mbox65
-rw-r--r--tests/samples/trailers-followup-partial-reroll-ref-defaults.txt73
-rw-r--r--tests/samples/trailers-followup-partial-reroll.mbox147
-rw-r--r--tests/samples/trailers-followup-single-ref-addlink.txt37
-rw-r--r--tests/samples/trailers-followup-single-ref-addmysob.txt36
-rw-r--r--tests/samples/trailers-followup-single-ref-copyccs.txt39
-rw-r--r--tests/samples/trailers-followup-single-ref-defaults.txt35
-rw-r--r--tests/samples/trailers-followup-single-ref-noadd.txt33
-rw-r--r--tests/samples/trailers-followup-single-ref-ordered.txt39
-rw-r--r--tests/samples/trailers-followup-single-ref-sloppy.txt37
-rw-r--r--tests/samples/trailers-followup-single.mbox78
-rw-r--r--tests/samples/trailers-followup-with-cover-ref-covertrailers.txt75
-rw-r--r--tests/samples/trailers-followup-with-cover-ref-defaults.txt73
-rw-r--r--tests/samples/trailers-followup-with-cover.mbox113
-rw-r--r--tests/samples/trailers-test-extinfo.txt29
-rw-r--r--tests/samples/trailers-test-simple.txt24
-rw-r--r--tests/test___init__.py67
36 files changed, 6476 insertions, 733 deletions
diff --git a/.keys/openpgp/chromium.org/keescook/default b/.keys/openpgp/chromium.org/keescook/default
new file mode 100644
index 0000000..c70cfe9
--- /dev/null
+++ b/.keys/openpgp/chromium.org/keescook/default
@@ -0,0 +1,1295 @@
+pub rsa4096 2010-09-27 [SC]
+ A5C3F68F229DD60F723E6E138972F4DFDC6DC026
+uid Kees Cook <kees@outflux.net>
+uid Kees Cook <kees@debian.org>
+uid Kees Cook <kees@kernel.org>
+uid Kees Cook <kees@ubuntu.com>
+uid Kees Cook <keescook@google.com>
+uid Kees Cook <keescook@chromium.org>
+uid [jpeg image of size 3658]
+sub rsa4096 2010-09-27 [E]
+ A0802D4E5EC6F03131FD4334BB36CCEA650DE414
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+=iG2I
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/README.rst b/README.rst
index e13367a..ca4718c 100644
--- a/README.rst
+++ b/README.rst
@@ -36,8 +36,8 @@ dependencies that are tracked via submodules::
git submodule update --init
-Patch attestation (EXPERIMENTAL)
---------------------------------
+Patch attestation
+-----------------
B4 implements two attestation verification mechanisms:
- DKIM attestation using the dkimpy library
diff --git a/b4/__init__.py b/b4/__init__.py
index e0a03db..f25b518 100644
--- a/b4/__init__.py
+++ b/b4/__init__.py
@@ -14,8 +14,10 @@ import email.header
import email.generator
import tempfile
import pathlib
+import argparse
+import smtplib
+import shlex
-import requests
import urllib.parse
import datetime
import time
@@ -25,8 +27,11 @@ import mailbox
# noinspection PyCompatibility
import pwd
+import requests
+
+from pathlib import Path
from contextlib import contextmanager
-from typing import Optional, Tuple, Set, List, TextIO
+from typing import Optional, Tuple, Set, List, TextIO, Union, Sequence
from email import charset
charset.add_charset('utf-8', None)
@@ -44,7 +49,8 @@ try:
except ModuleNotFoundError:
can_patatt = False
-__VERSION__ = '0.8-dev'
+__VERSION__ = '0.10.0-dev'
+PW_REST_API_VERSION = '1.2'
def _dkim_log_filter(record):
@@ -84,25 +90,12 @@ AMHDRS = [
'List-Id',
]
-# You can use bash-style globbing here
-# end with '*' to include any other trailers
-# You can change the default in your ~/.gitconfig, e.g.:
-# [b4]
-# # remember to end with ,*
-# trailer-order=link*,fixes*,cc*,reported*,suggested*,original*,co-*,tested*,reviewed*,acked*,signed-off*,*
-# (another common)
-# trailer-order=fixes*,reported*,suggested*,original*,co-*,signed-off*,tested*,reviewed*,acked*,cc*,link*,*
-#
-# Or use _preserve_ (alias to *) to keep the order unchanged
-
-DEFAULT_TRAILER_ORDER = '*'
-
LOREADDR = 'https://lore.kernel.org'
DEFAULT_CONFIG = {
- 'midmask': LOREADDR + '/r/%s',
+ 'midmask': LOREADDR + '/all/%s',
'linkmask': LOREADDR + '/r/%s',
- 'trailer-order': DEFAULT_TRAILER_ORDER,
+ 'searchmask': LOREADDR + '/all/?x=m&t=1&q=%s',
'listid-preference': '*.feeds.kernel.org,*.linux.dev,*.kernel.org,*',
'save-maildirs': 'no',
# off: do not bother checking attestation
@@ -130,6 +123,8 @@ DEFAULT_CONFIG = {
# git-config for gpg.program, and if that's not set,
# we'll use "gpg" and hope for the better
'gpgbin': None,
+ # When sending mail, use this sendemail identity configuration
+ 'sendemail-identity': None,
}
# This is where we store actual config
@@ -141,6 +136,8 @@ USER_CONFIG = None
REQSESSION = None
# Indicates that we've cleaned cache already
_CACHE_CLEANED = False
+# Used to track mailmap replacements
+MAILMAP_INFO = dict()
class LoreMailbox:
@@ -171,7 +168,7 @@ class LoreMailbox:
return '\n'.join(out)
- def get_by_msgid(self, msgid):
+ def get_by_msgid(self, msgid: str) -> Optional['LoreMessage']:
if msgid in self.msgid_map:
return self.msgid_map[msgid]
return None
@@ -238,7 +235,7 @@ class LoreMailbox:
lser.subject = pser.subject
logger.debug('Reconstituted successfully')
- def get_series(self, revision=None, sloppytrailers=False, reroll=True):
+ def get_series(self, revision=None, sloppytrailers=False, reroll=True) -> Optional['LoreSeries']:
if revision is None:
if not len(self.series):
return None
@@ -307,14 +304,14 @@ class LoreMailbox:
continue
trailers, mismatches = fmsg.get_trailers(sloppy=sloppytrailers)
- for trailer in mismatches:
- lser.trailer_mismatches.add((trailer[0], trailer[1], fmsg.fromname, fmsg.fromemail))
+ for ltr in mismatches:
+ lser.trailer_mismatches.add((ltr.name, ltr.value, fmsg.fromname, fmsg.fromemail))
lvl = 1
while True:
logger.debug('%sParent: %s', ' ' * lvl, pmsg.full_subject)
logger.debug('%sTrailers:', ' ' * lvl)
- for trailer in trailers:
- logger.debug('%s%s: %s', ' ' * (lvl+1), trailer[0], trailer[1])
+ for ltr in trailers:
+ logger.debug('%s%s: %s', ' ' * (lvl+1), ltr.name, ltr.value)
if pmsg.has_diff and not pmsg.reply:
# We found the patch for these trailers
if pmsg.revision != revision:
@@ -333,8 +330,9 @@ class LoreMailbox:
break
if pmsg.in_reply_to and pmsg.in_reply_to in self.msgid_map:
lvl += 1
- for ptrailer in pmsg.trailers:
- trailers.append(tuple(ptrailer + [pmsg]))
+ for pltr in pmsg.trailers:
+ pltr.lmsg = pmsg
+ trailers.append(pltr)
pmsg = self.msgid_map[pmsg.in_reply_to]
continue
break
@@ -348,15 +346,17 @@ class LoreMailbox:
return lser
- def add_message(self, msg):
+ def add_message(self, msg: email.message.Message) -> None:
msgid = LoreMessage.get_clean_msgid(msg)
- if msgid in self.msgid_map:
+ if msgid and msgid in self.msgid_map:
logger.debug('Already have a message with this msgid, skipping %s', msgid)
return
lmsg = LoreMessage(msg)
logger.debug('Looking at: %s', lmsg.full_subject)
- self.msgid_map[lmsg.msgid] = lmsg
+
+ if msgid:
+ self.msgid_map[lmsg.msgid] = lmsg
if lmsg.reply:
# We'll figure out where this belongs later
@@ -393,7 +393,7 @@ class LoreMailbox:
logger.debug('Found new series v%s', lmsg.revision)
# Attempt to auto-number series from the same author who did not bother
- # to set v2, v3, etc in the patch revision
+ # to set v2, v3, etc. in the patch revision
if (lmsg.counter == 1 and lmsg.counters_inferred
and not lmsg.reply and lmsg.lsubject.patch and not lmsg.lsubject.resend):
omsg = self.series[lmsg.revision].patches[lmsg.counter]
@@ -411,18 +411,26 @@ class LoreMailbox:
class LoreSeries:
- def __init__(self, revision, expected):
+ revision: int
+ expected: int
+ patches: List[Optional['LoreMessage']]
+ followups: List['LoreMessage']
+ trailer_mismatches: Set[Tuple[str, str, str, str]]
+ complete: bool = False
+ has_cover: bool = False
+ partial_reroll: bool = False
+ subject: str
+ indexes: Optional[List[Tuple[str, str]]] = None
+ base_commit: Optional[str] = None
+ change_id: Optional[str] = None
+
+ def __init__(self, revision: int, expected: int) -> None:
self.revision = revision
self.expected = expected
self.patches = [None] * (expected+1)
self.followups = list()
self.trailer_mismatches = set()
- self.complete = False
- self.has_cover = False
- self.partial_reroll = False
self.subject = '(untitled)'
- # Used for base matching
- self._indexes = None
def __repr__(self):
out = list()
@@ -431,6 +439,8 @@ class LoreSeries:
out.append(' expected: %s' % self.expected)
out.append(' complete: %s' % self.complete)
out.append(' has_cover: %s' % self.has_cover)
+ out.append(' base_commit: %s' % self.base_commit)
+ out.append(' change_id: %s' % self.change_id)
out.append(' partial_reroll: %s' % self.partial_reroll)
out.append(' patches:')
at = 0
@@ -443,7 +453,7 @@ class LoreSeries:
return '\n'.join(out)
- def add_patch(self, lmsg):
+ def add_patch(self, lmsg: 'LoreMessage') -> None:
while len(self.patches) < lmsg.expected + 1:
self.patches.append(None)
self.expected = lmsg.expected
@@ -457,14 +467,23 @@ class LoreSeries:
else:
self.patches[lmsg.counter] = lmsg
self.complete = not (None in self.patches[1:])
+ if lmsg.counter == 0:
+ # This is a cover letter
+ if '\nbase-commit:' in lmsg.body:
+ matches = re.search(r'^base-commit: .*?([\da-f]+)', lmsg.body, flags=re.I | re.M)
+ if matches:
+ self.base_commit = matches.groups()[0]
+ if '\nchange-id:' in lmsg.body:
+ matches = re.search(r'^change-id:\s+(\S+)', lmsg.body, flags=re.I | re.M)
+ if matches:
+ self.change_id = matches.groups()[0]
+
if self.patches[0] is not None:
- # noinspection PyUnresolvedReferences
self.subject = self.patches[0].subject
elif self.patches[1] is not None:
- # noinspection PyUnresolvedReferences
self.subject = self.patches[1].subject
- def get_slug(self, extended=False):
+ def get_slug(self, extended: bool = False) -> str:
# Find the first non-None entry
lmsg = None
for lmsg in self.patches:
@@ -489,8 +508,18 @@ class LoreSeries:
return slug[:100]
- def get_am_ready(self, noaddtrailers=False, covertrailers=False, trailer_order=None, addmysob=False,
- addlink=False, linkmask=None, cherrypick=None, copyccs=False) -> list:
+ def add_extra_trailers(self, trailers: tuple) -> None:
+ for lmsg in self.patches[1:]:
+ if lmsg is None:
+ continue
+ lmsg.followup_trailers += trailers
+
+ def add_cover_trailers(self) -> None:
+ if self.patches[0] and self.patches[0].followup_trailers: # noqa
+ self.add_extra_trailers(self.patches[0].followup_trailers) # noqa
+
+ def get_am_ready(self, noaddtrailers=False, covertrailers=False, addmysob=False, addlink=False,
+ linkmask=None, cherrypick=None, copyccs=False, allowbadchars=False) -> List[email.message.Message]:
usercfg = get_user_config()
config = get_main_config()
@@ -531,6 +560,9 @@ class LoreSeries:
logger.debug('Attestation info is not the same')
break
+ if covertrailers:
+ self.add_cover_trailers()
+
at = 1
msgs = list()
logger.info('---')
@@ -545,13 +577,13 @@ class LoreSeries:
raise KeyError('Cherrypick not in series')
if lmsg is not None:
- if self.has_cover and covertrailers and self.patches[0].followup_trailers: # noqa
- lmsg.followup_trailers += self.patches[0].followup_trailers # noqa
- if addmysob:
- lmsg.followup_trailers.append(('Signed-off-by',
- '%s <%s>' % (usercfg['name'], usercfg['email']), None, None))
+ extras = list()
if addlink:
- lmsg.followup_trailers.append(('Link', linkmask % lmsg.msgid, None, None))
+ if linkmask is None:
+ linkmask = config.get('linkmask')
+ linkval = linkmask % lmsg.msgid
+ lltr = LoreTrailer(name='Link', value=linkval)
+ extras.append(lltr)
if attsame and not attcrit:
if attmark:
@@ -578,7 +610,8 @@ class LoreSeries:
add_trailers = True
if noaddtrailers:
add_trailers = False
- msg = lmsg.get_am_message(add_trailers=add_trailers, trailer_order=trailer_order, copyccs=copyccs)
+ msg = lmsg.get_am_message(add_trailers=add_trailers, extras=extras, copyccs=copyccs,
+ addmysob=addmysob, allowbadchars=allowbadchars)
msgs.append(msg)
else:
logger.error(' ERROR: missing [%s/%s]!', at, self.expected)
@@ -601,28 +634,31 @@ class LoreSeries:
return msgs
- def check_applies_clean(self, gitdir: str, at: Optional[str] = None) -> Tuple[int, list]:
- if self._indexes is None:
- self._indexes = list()
- seenfiles = set()
- for lmsg in self.patches[1:]:
- if lmsg is None or lmsg.blob_indexes is None:
+ def populate_indexes(self):
+ self.indexes = list()
+ seenfiles = set()
+ for lmsg in self.patches[1:]:
+ if lmsg is None or lmsg.blob_indexes is None:
+ continue
+ for fn, bh in lmsg.blob_indexes:
+ if fn in seenfiles:
+ # if we have seen this file once already, then it's a repeat patch
+ # it's no longer going to match current hash
continue
- for fn, bh in lmsg.blob_indexes:
- if fn in seenfiles:
- # if we have seen this file once already, then it's a repeat patch
- # and it's no longer going to match current hash
- continue
- seenfiles.add(fn)
- if set(bh) == {'0'}:
- # New file, will for sure apply clean
- continue
- self._indexes.append((fn, bh))
+ seenfiles.add(fn)
+ if set(bh) == {'0'}:
+ # New file, will for sure apply clean
+ continue
+ self.indexes.append((fn, bh))
+
+ def check_applies_clean(self, gitdir: str, at: Optional[str] = None) -> Tuple[int, list]:
+ if self.indexes is None:
+ self.populate_indexes()
mismatches = list()
if at is None:
at = 'HEAD'
- for fn, bh in self._indexes:
+ for fn, bh in self.indexes:
ecode, out = git_run_command(gitdir, ['ls-tree', at, fn])
if ecode == 0 and len(out):
chunks = out.split()
@@ -636,9 +672,9 @@ class LoreSeries:
logger.debug('Could not look up %s:%s', at, fn)
mismatches.append((fn, bh))
- return len(self._indexes), mismatches
+ return len(self.indexes), mismatches
- def find_base(self, gitdir: str, branches: Optional[str] = None, maxdays: int = 30) -> Tuple[str, len, len]:
+ def find_base(self, gitdir: str, branches: Optional[list] = None, maxdays: int = 30) -> Tuple[str, len, len]:
# Find the date of the first patch we have
pdate = datetime.datetime.now()
for lmsg in self.patches:
@@ -647,10 +683,10 @@ class LoreSeries:
pdate = lmsg.date
break
- # Find latest commit on that date
+ # Find the latest commit on that date
guntil = pdate.strftime('%Y-%m-%d')
if branches:
- where = ['--branches', branches]
+ where = branches
else:
where = ['--all']
@@ -677,7 +713,7 @@ class LoreSeries:
for line in lines:
commit = line.split()[0]
logger.debug('commit=%s', commit)
- # We try both that commit and the one preceding it, in case it was a delete
+ # We try both that commit and the one preceding it, in case it was a deletion
# Keep track of the fewest mismatches
for tc in [commit, f'{commit}~1']:
sc, sm = self.check_applies_clean(gitdir, tc)
@@ -695,13 +731,13 @@ class LoreSeries:
break
else:
best = commit
- if fewest == len(self._indexes):
+ if fewest == len(self.indexes):
# None of the blobs matched
raise IndexError
lines = git_get_command_lines(gitdir, ['describe', '--all', best])
if len(lines):
- return lines[0], len(self._indexes), fewest
+ return lines[0], len(self.indexes), fewest
raise IndexError
@@ -813,12 +849,107 @@ class LoreSeries:
def save_cover(self, outfile):
# noinspection PyUnresolvedReferences
- cover_msg = self.patches[0].get_am_message(add_trailers=False, trailer_order=None)
+ cover_msg = self.patches[0].get_am_message(add_trailers=False)
with open(outfile, 'w') as fh:
fh.write(cover_msg.as_string(policy=emlpolicy))
logger.critical('Cover: %s', outfile)
+class LoreTrailer:
+ type: str
+ name: str
+ lname: str
+ value: str
+ extinfo: Optional[str] = None
+ addr: Optional[Tuple[str, str]] = None
+ lmsg = None
+ # Small list of recognized utility trailers
+ _utility: Set[str] = {'fixes', 'link', 'buglink', 'obsoleted-by', 'message-id', 'change-id'}
+
+ def __init__(self, name: Optional[str] = None, value: Optional[str] = None, extinfo: Optional[str] = None,
+ msg: Optional[email.message.Message] = None):
+ if name is None:
+ self.name = 'Signed-off-by'
+ ucfg = get_user_config()
+ self.value = '%s <%s>' % (ucfg['name'], ucfg['email'])
+ self.type = 'person'
+ self.addr = (ucfg['name'], ucfg['email'])
+ else:
+ self.name = name
+ self.value = value
+ if name.lower() in self._utility:
+ self.type = 'utility'
+ elif re.search(r'\S+@\S+\.\S+', value):
+ self.type = 'person'
+ self.addr = email.utils.parseaddr(value)
+ else:
+ self.type = 'unknown'
+ self.lname = self.name.lower()
+ self.extinfo = extinfo
+ self.msg = msg
+
+ def as_string(self, omit_extinfo: bool = False) -> str:
+ ret = f'{self.name}: {self.value}'
+ if not self.extinfo or omit_extinfo:
+ return ret
+ # extinfo can be either be [on the next line], or # at the end
+ if self.extinfo.lstrip()[0] == '#':
+ ret += self.extinfo
+ else:
+ ret += f'\n{self.extinfo}'
+
+ return ret
+
+ def email_eq(self, cmp_email: str, fuzzy: bool = True) -> bool:
+ if not self.addr:
+ return False
+ our = self.addr[1].lower()
+ their = cmp_email.lower()
+ if our == their:
+ return True
+ if not fuzzy:
+ return False
+
+ if '@' not in our or '@' not in their:
+ return False
+
+ # Strip extended local parts often added by people, e.g.:
+ # comparing foo@example.com and foo+kernel@example.com should match
+ our = re.sub(r'\+[^@]+@', '@', our)
+ their = re.sub(r'\+[^@]+@', '@', their)
+ if our == their:
+ return True
+
+ # See if domain part of one of the addresses is a subset of the other one,
+ # which should match cases like foo@linux.intel.com and foo@intel.com
+ olocal, odomain = our.split('@', maxsplit=1)
+ tlocal, tdomain = their.split('@', maxsplit=1)
+ if olocal != tlocal:
+ return False
+
+ if (abs(odomain.count('.')-tdomain.count('.')) == 1
+ and (odomain.endswith(f'.{tdomain}') or tdomain.endswith(f'.{odomain}'))):
+ return True
+
+ return False
+
+ def __eq__(self, other):
+ # We never compare extinfo, we just tack it if we find a match
+ return self.lname == other.lname and self.value.lower() == other.value.lower()
+
+ def __hash__(self):
+ return hash(f'{self.lname}: {self.value}')
+
+ def __repr__(self):
+ out = list()
+ out.append(' type: %s' % self.type)
+ out.append(' name: %s' % self.name)
+ out.append(' value: %s' % self.value)
+ out.append(' extinfo: %s' % self.extinfo)
+
+ return '\n'.join(out)
+
+
class LoreMessage:
def __init__(self, msg):
self.msg = msg
@@ -844,6 +975,7 @@ class LoreMessage:
# Body and body-based info
self.body = None
+ self.message = None
self.charset = 'utf-8'
self.has_diff = False
self.has_diffstat = False
@@ -903,8 +1035,8 @@ class LoreMessage:
if self.date.tzinfo is None:
self.date = self.date.replace(tzinfo=datetime.timezone.utc)
- diffre = re.compile(r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', re.M | re.I)
- diffstatre = re.compile(r'^\s*\d+ file.*\d+ (insertion|deletion)', re.M | re.I)
+ diffre = re.compile(r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I)
+ diffstatre = re.compile(r'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.M | re.I)
# walk until we find the first text/plain part
mcharset = self.msg.get_content_charset()
@@ -957,41 +1089,33 @@ class LoreMessage:
trailers, others = LoreMessage.find_trailers(self.body, followup=True)
for trailer in trailers:
# These are commonly part of patch/commit metadata
- badtrailers = ('from', 'author', 'cc', 'to')
- if trailer[0].lower() not in badtrailers:
+ badtrailers = {'from', 'author', 'cc', 'to'}
+ if trailer.lname not in badtrailers:
self.trailers.append(trailer)
- def get_trailers(self, sloppy=False):
+ def get_trailers(self, sloppy: bool = False) -> Tuple[List[LoreTrailer], Set[LoreTrailer]]:
trailers = list()
mismatches = set()
- for tname, tvalue, extdata in self.trailers:
- if sloppy or tname.lower() in ('fixes', 'obsoleted-by'):
- trailers.append((tname, tvalue, extdata, self))
+ for ltr in self.trailers:
+ ltr.lmsg = self
+ if sloppy or ltr.type != 'person':
+ trailers.append(ltr)
+ continue
+
+ if ltr.email_eq(self.fromemail):
+ logger.debug(' trailer email match')
+ trailers.append(ltr)
continue
- tmatch = False
- namedata = email.utils.getaddresses([tvalue])[0]
- tfrom = re.sub(r'\+[^@]+@', '@', namedata[1].lower())
- hfrom = re.sub(r'\+[^@]+@', '@', self.fromemail.lower())
- tlname = namedata[0].lower()
- hlname = self.fromname.lower()
- tchunks = tfrom.split('@')
- hchunks = hfrom.split('@')
- if tfrom == hfrom:
- logger.debug(' trailer exact email match')
- tmatch = True
- # See if domain part of one of the addresses is a subset of the other one,
- # which should match cases like @linux.intel.com and @intel.com
- elif (len(tchunks) == 2 and len(hchunks) == 2
- and tchunks[0] == hchunks[0]
- and (tchunks[1].find(hchunks[1]) >= 0 or hchunks[1].find(tchunks[1]) >= 0)):
- logger.debug(' trailer fuzzy email match')
- tmatch = True
# Does the name match, at least?
- elif tlname == hlname:
+ nmatch = False
+ tlname = ltr.addr[0].lower()
+ hlname = self.fromname.lower()
+
+ if tlname == hlname:
logger.debug(' trailer exact name match')
- tmatch = True
+ nmatch = True
# Finally, see if the header From has a comma in it and try to find all
# parts in the trailer name
elif hlname.find(',') > 0:
@@ -1000,13 +1124,13 @@ class LoreMessage:
if hlname.find(nchunk.strip()) < 0:
nmatch = False
break
- if nmatch:
- logger.debug(' trailer fuzzy name match')
- tmatch = True
- if tmatch:
- trailers.append((tname, tvalue, extdata, self))
- else:
- mismatches.add((tname, tvalue, extdata, self))
+ if nmatch:
+ logger.debug(' trailer fuzzy name match')
+ trailers.append(ltr)
+ continue
+
+ logger.debug('trailer did not match: %s: %s', ltr.name, ltr.value)
+ mismatches.add(ltr)
return trailers, mismatches
@@ -1115,7 +1239,11 @@ class LoreMessage:
config = get_main_config()
sources = config.get('keyringsrc')
if not sources:
- sources = ['ref:::.keys', 'ref:::.local-keys', 'ref::refs/meta/keyring:']
+ # fallback to patatt's keyring if none is specified for b4
+ patatt_config = patatt.get_config_from_git(r'patatt\..*', multivals=['keyringsrc'])
+ sources = patatt_config.get('keyringsrc')
+ if not sources:
+ sources = ['ref:::.keys', 'ref:::.local-keys', 'ref::refs/meta/keyring:']
if pdir not in sources:
sources.append(pdir)
@@ -1193,9 +1321,27 @@ class LoreMessage:
if attpolicy == 'hardfail':
critical = True
else:
+ passing = False
if not checkmark:
checkmark = attestor.checkmark
if attestor.check_identity(self.fromemail):
+ passing = True
+ else:
+ # Do we have an x-original-from?
+ xofh = self.msg.get('X-Original-From')
+ if xofh:
+ logger.debug('Using X-Original-From for identity check')
+ xpair = email.utils.getaddresses([xofh])[0]
+ if attestor.check_identity(xpair[1]):
+ passing = True
+ # Fix our fromname and fromemail, mostly for thanks-tracking
+ self.fromname = xpair[0]
+ self.fromemail = xpair[1]
+ # Drop the reply-to header if it's exactly the same
+ for header in list(self.msg._headers): # noqa
+ if header[0].lower() == 'reply-to' and header[1].find(xpair[1]) > 0:
+ self.msg._headers.remove(header) # noqa
+ if passing:
trailers.append('%s Signed: %s' % (attestor.checkmark, attestor.trailer))
else:
trailers.append('%s Signed: %s (From: %s)' % (attestor.checkmark, attestor.trailer,
@@ -1306,6 +1452,14 @@ class LoreMessage:
return msg2
@staticmethod
+ def get_patch_id(diff: str) -> Optional[str]:
+ gitargs = ['patch-id', '--stable']
+ ecode, out = git_run_command(None, gitargs, stdin=diff.encode())
+ if ecode > 0 or not len(out.strip()):
+ return None
+ return out.split(maxsplit=1)[0]
+
+ @staticmethod
def get_patchwork_hash(diff: str) -> str:
"""Generate a hash from a diff. Lifted verbatim from patchwork."""
@@ -1359,13 +1513,13 @@ class LoreMessage:
if matches and matches.groups()[0] == matches.groups()[1]:
curfile = matches.groups()[0]
continue
- matches = re.search(r'^index\s+([0-9a-f]+)\.\.[0-9a-f]+.*$', line)
+ matches = re.search(r'^index\s+([\da-f]+)\.\.[\da-f]+.*$', line)
if matches and curfile is not None:
indexes.add((curfile, matches.groups()[0]))
return indexes
@staticmethod
- def find_trailers(body, followup=False):
+ def find_trailers(body: str, followup: bool = False) -> Tuple[List[LoreTrailer], List[str]]:
ignores = {'phone', 'email'}
headers = {'subject', 'date', 'from'}
nonperson = {'fixes', 'subject', 'date', 'link', 'buglink', 'obsoleted-by'}
@@ -1374,7 +1528,7 @@ class LoreMessage:
# Fix some more common copypasta trailer wrapping
# Fixes: abcd0123 (foo bar
# baz quux)
- body = re.sub(r'^(\S+:\s+[0-9a-f]+\s+\([^)]+)\n([^\n]+\))', r'\1 \2', body, flags=re.M)
+ body = re.sub(r'^(\S+:\s+[\da-f]+\s+\([^)]+)\n([^\n]+\))', r'\1 \2', body, flags=re.M)
# Signed-off-by: Long Name
# <email.here@example.com>
body = re.sub(r'^(\S+:\s+[^<]+)\n(<[^>]+>)$', r'\1 \2', body, flags=re.M)
@@ -1388,31 +1542,47 @@ class LoreMessage:
was_trailer = False
for line in body.split('\n'):
line = line.strip('\r')
- matches = re.search(r'^(\w\S+):\s+(\S.*)', line, flags=re.I)
+ matches = re.search(r'^\s*(\w\S+):\s+(\S.*)', line, flags=re.I)
if matches:
- groups = list(matches.groups())
+ oname, ovalue = list(matches.groups())
# We only accept headers if we haven't seen any non-trailer lines
- tname = groups[0].lower()
- if tname in ignores:
+ lname = oname.lower()
+ if lname in ignores:
logger.debug('Ignoring known non-trailer: %s', line)
continue
- if len(others) and tname in headers:
+ if len(others) and lname in headers:
logger.debug('Ignoring %s (header after other content)', line)
continue
if followup:
- mperson = re.search(r'\S+@\S+\.\S+', groups[1])
- if not mperson and tname not in nonperson:
+ if not lname.isascii():
+ logger.debug('Ignoring known non-ascii follow-up trailer: %s', lname)
+ continue
+ mperson = re.search(r'\S+@\S+\.\S+', ovalue)
+ if not mperson and lname not in nonperson:
logger.debug('Ignoring %s (not a recognized non-person trailer)', line)
continue
+ if re.search(r'https?://', ovalue):
+ logger.debug('Ignoring %s (not a recognized link trailer)', line)
+ continue
+
+ extinfo = None
+ mextinfo = re.search(r'(.*\S+)(\s+#[^#]+)$', ovalue)
+ if mextinfo:
+ logger.debug('Trailer contains hashtag extinfo: %s', line)
+ # Found extinfo of the hashtag genre
+ egr = mextinfo.groups()
+ ovalue = egr[0]
+ extinfo = egr[1]
+
was_trailer = True
- groups.append(None)
- trailers.append(groups)
+ ltrailer = LoreTrailer(name=oname, value=ovalue, extinfo=extinfo)
+ trailers.append(ltrailer)
continue
# Is it an extended info line, e.g.:
# Signed-off-by: Foo Foo <foo@foo.com>
# [for the foo bits]
- if len(line) > 2 and line[0] == '[' and line[-1] == ']' and was_trailer:
- trailers[-1][2] = line
+ if len(line) > 2 and was_trailer and re.search(r'^\s*\[[^]]+]\s*$', line):
+ trailers[-1].extinfo = line
was_trailer = False
continue
was_trailer = False
@@ -1421,7 +1591,7 @@ class LoreMessage:
return trailers, others
@staticmethod
- def get_body_parts(body):
+ def get_body_parts(body: str) -> Tuple[List[LoreTrailer], str, List[LoreTrailer], str, str]:
# remove any starting/trailing blank lines
body = body.replace('\r', '')
body = body.strip('\n')
@@ -1484,13 +1654,28 @@ class LoreMessage:
return githeaders, message, trailers, basement, signature
- def fix_trailers(self, trailer_order=None, copyccs=False):
+ def fix_trailers(self, extras: Optional[List[LoreTrailer]] = None,
+ copyccs: bool = False, addmysob: bool = False) -> None:
+
config = get_main_config()
- attpolicy = config['attestation-policy']
bheaders, message, btrailers, basement, signature = LoreMessage.get_body_parts(self.body)
- # Now we add mix-in trailers
- trailers = btrailers + self.followup_trailers
+
+ sobtr = LoreTrailer()
+ hasmysob = False
+ if sobtr in btrailers:
+ # Our own signoff always moves to the bottom of all trailers
+ hasmysob = True
+ btrailers.remove(sobtr)
+
+ new_trailers = self.followup_trailers
+ if extras:
+ new_trailers += extras
+
+ if sobtr in new_trailers:
+ # Our own signoff always moves to the bottom of all trailers
+ new_trailers.remove(sobtr)
+ addmysob = True
if copyccs:
alldests = email.utils.getaddresses([str(x) for x in self.msg.get_all('to', [])])
@@ -1499,74 +1684,98 @@ class LoreMessage:
alldests.sort(key=lambda x: x[1].find('@') > 0 and x[1].split('@')[1] + x[1].split('@')[0] or x[1])
for pair in alldests:
found = False
- for ftr in trailers:
- if ftr[1].lower().find(pair[1].lower()) >= 0:
+ for fltr in btrailers + new_trailers:
+ if fltr.email_eq(pair[1]):
# already present
found = True
break
if not found:
if len(pair[0]):
- trailers.append(('Cc', f'{pair[0]} <{pair[1]}>', None, None)) # noqa
+ altr = LoreTrailer(name='Cc', value=f'{pair[0]} <{pair[1]}>')
else:
- trailers.append(('Cc', pair[1], None, None)) # noqa
+ altr = LoreTrailer(name='Cc', value=pair[1])
+ new_trailers.append(altr)
+
+ torder = config.get('trailer-order')
+ if torder and torder != '*':
+ # this only applies to trailers within our chain of custody, so walk existing
+ # body trailers backwards and stop at the outermost Signed-off-by we find (if any)
+ for bltr in reversed(btrailers):
+ if bltr.lname == 'signed-off-by':
+ break
+ btrailers.remove(bltr)
+ new_trailers.insert(0, bltr)
- fixtrailers = list()
- if trailer_order is None:
- trailer_order = DEFAULT_TRAILER_ORDER
- elif trailer_order in ('preserve', '_preserve_'):
- trailer_order = '*'
+ ordered_trailers = list()
+ for glob in [x.strip().lower() for x in torder.split(',')]:
+ if not len(new_trailers):
+ break
+ for ltr in list(new_trailers):
+ if fnmatch.fnmatch(ltr.lname, glob):
+ ordered_trailers.append(ltr)
+ new_trailers.remove(ltr)
+ if len(new_trailers):
+ # Tack them to the bottom
+ ordered_trailers += new_trailers
+ new_trailers = ordered_trailers
- for trailermatch in trailer_order:
- for trailer in trailers:
- if list(trailer[:3]) in fixtrailers:
- # Dupe
- continue
- if fnmatch.fnmatch(trailer[0].lower(), trailermatch.strip()):
- fixtrailers.append(list(trailer[:3]))
- if trailer[:3] not in btrailers:
- extra = ''
- if trailer[3] is not None:
- fmsg = trailer[3]
- for attestor in fmsg.attestors: # noqa
- if attestor.passing:
- extra = ' (%s %s)' % (attestor.checkmark, attestor.trailer)
- elif attpolicy in ('hardfail', 'softfail'):
- extra = ' (%s %s)' % (attestor.checkmark, attestor.trailer)
- if attpolicy == 'hardfail':
- import sys
- logger.critical('---')
- logger.critical('Exiting due to attestation-policy: hardfail')
- sys.exit(1)
-
- logger.info(' + %s: %s%s', trailer[0], trailer[1], extra)
- else:
- logger.debug(' . %s: %s', trailer[0], trailer[1])
+ attpolicy = config['attestation-policy']
+ fixtrailers = btrailers
+
+ for ltr in new_trailers:
+ if ltr in fixtrailers:
+ continue
+
+ fixtrailers.append(ltr)
+ extra = ''
+ if ltr.lmsg is not None:
+ for attestor in ltr.lmsg.attestors:
+ if attestor.passing:
+ extra = ' (%s %s)' % (attestor.checkmark, attestor.trailer)
+ elif attpolicy in ('hardfail', 'softfail'):
+ extra = ' (%s %s)' % (attestor.checkmark, attestor.trailer)
+ if attpolicy == 'hardfail':
+ import sys
+ logger.critical('---')
+ logger.critical('Exiting due to attestation-policy: hardfail')
+ sys.exit(1)
+
+ logger.info(' + %s%s', ltr.as_string(omit_extinfo=True), extra)
+
+ if addmysob or hasmysob:
+ # Tack on our signoff at the bottom
+ fixtrailers.append(sobtr)
+ if not hasmysob:
+ logger.info(' + %s', sobtr.as_string(omit_extinfo=True))
# Reconstitute the message
self.body = ''
if bheaders:
- for bheader in bheaders:
+ for bltr in bheaders:
# There is no [extdata] in git headers, so we ignore bheader[2]
- self.body += '%s: %s\n' % (bheader[0], bheader[1])
+ self.body += bltr.as_string(omit_extinfo=True) + '\n'
self.body += '\n'
+ newmessage = ''
if len(message):
- self.body += message.rstrip('\r\n') + '\n'
+ newmessage += message.rstrip('\r\n') + '\n'
if len(fixtrailers):
- self.body += '\n'
+ newmessage += '\n'
if len(fixtrailers):
- for trailer in fixtrailers:
- self.body += '%s: %s\n' % (trailer[0], trailer[1])
- if trailer[2]:
- self.body += '%s\n' % trailer[2]
+ for ltr in fixtrailers:
+ newmessage += ltr.as_string() + '\n'
+
+ self.message = self.subject + '\n\n' + newmessage
+ self.body += newmessage
+
if len(basement):
self.body += '---\n'
- self.body += basement.rstrip('\r\n') + '\n\n'
+ self.body += basement.rstrip('\r\n') + '\n'
if len(signature):
self.body += '-- \n'
- self.body += signature.rstrip('\r\n') + '\n\n'
+ self.body += signature.rstrip('\r\n') + '\n'
def get_am_subject(self, indicate_reroll=True):
# Return a clean patch subject
@@ -1588,11 +1797,39 @@ class LoreMessage:
return '[%s] %s' % (' '.join(parts), self.lsubject.subject)
- def get_am_message(self, add_trailers=True, trailer_order=None, copyccs=False):
+ def get_am_message(self, add_trailers=True, addmysob=False, extras=None, copyccs=False, allowbadchars=False):
if add_trailers:
- self.fix_trailers(trailer_order=trailer_order, copyccs=copyccs)
+ self.fix_trailers(copyccs=copyccs, addmysob=addmysob, extras=extras)
+ bbody = self.body.encode()
+ # Look through the body to make sure there aren't any suspicious unicode control flow chars
+ # First, encode into ascii and compare for a quickie utf8 presence test
+ if not allowbadchars and self.body.encode('ascii', errors='replace') != bbody:
+ import unicodedata
+ logger.debug('Body contains non-ascii characters. Running Unicode Cf char tests.')
+ for line in self.body.split('\n'):
+ # Does this line have any unicode?
+ if line.encode() == line.encode('ascii', errors='replace'):
+ continue
+ ucats = {unicodedata.category(ch) for ch in line.rstrip('\r')}
+ # If we have Cf (control flow characters) but not Lo ("letter other") characters,
+ # indicating a language other than latin, then there's likely something funky going on
+ if 'Cf' in ucats and 'Lo' not in ucats:
+ # find the offending char
+ at = 0
+ for c in line.rstrip('\r'):
+ if unicodedata.category(c) == 'Cf':
+ logger.critical('---')
+ logger.critical('WARNING: Message contains suspicious unicode control characters!')
+ logger.critical(' Subject: %s', self.full_subject)
+ logger.critical(' Line: %s', line.rstrip('\r'))
+ logger.critical(' ------%s^', '-'*at)
+ logger.critical(' Char: %s (%s)', unicodedata.name(c), hex(ord(c)))
+ logger.critical(' If you are sure about this, rerun with the right flag to allow.')
+ sys.exit(1)
+ at += 1
+
am_msg = email.message.EmailMessage()
- am_msg.set_payload(self.body.encode())
+ am_msg.set_payload(bbody)
am_msg.add_header('Subject', self.get_am_subject(indicate_reroll=False))
if self.fromname:
am_msg.add_header('From', f'{self.fromname} <{self.fromemail}>')
@@ -1680,9 +1917,11 @@ class LoreSubject:
subject = re.sub(r'^\s*\[[^]]*]\s*', '', subject)
self.subject = subject
- def get_slug(self):
- unsafe = '%04d_%s' % (self.counter, self.subject)
- return re.sub(r'\W+', '_', unsafe).strip('_').lower()
+ def get_slug(self, sep='_', with_counter: bool = True):
+ unsafe = self.subject
+ if with_counter:
+ unsafe = '%04d%s%s' % (self.counter, sep, unsafe)
+ return re.sub(r'\W+', sep, unsafe).strip(sep).lower()
def __repr__(self):
out = list()
@@ -1742,7 +1981,7 @@ class LoreAttestor:
else:
mode = self.mode
- return '%s/%s' % (mode, self.identity)
+ return '%s/%s' % (mode, self.identity.lower())
def check_time_drift(self, emldate, maxdays: int = 30) -> bool:
if not self.passing or self.signtime is None:
@@ -1763,13 +2002,13 @@ class LoreAttestor:
return False
if self.level == 'domain':
- if emlfrom.endswith('@' + self.identity):
+ if emlfrom.lower().endswith('@' + self.identity.lower()):
logger.debug('PASS : sig domain %s matches from identity %s', self.identity, emlfrom)
return True
self.errors.append('signing domain %s does not match From: %s' % (self.identity, emlfrom))
return False
- if emlfrom == self.identity:
+ if emlfrom.lower() == self.identity.lower():
logger.debug('PASS : sig identity %s matches from identity %s', self.identity, emlfrom)
return True
self.errors.append('signing identity %s does not match From: %s' % (self.identity, emlfrom))
@@ -1805,8 +2044,11 @@ class LoreAttestorDKIM(LoreAttestor):
self.keysrc = 'DNS'
self.signtime = signtime
self.passing = passing
- self.identity = identity.lstrip('@')
self.errors = errors
+ if identity.find('@') >= 0:
+ self.identity = identity.split('@')[1]
+ else:
+ self.identity = identity
class LoreAttestorPatatt(LoreAttestor):
@@ -1827,10 +2069,21 @@ class LoreAttestorPatatt(LoreAttestor):
self.have_key = True
-def _run_command(cmdargs: list, stdin: Optional[bytes] = None) -> Tuple[int, bytes, bytes]:
+def _run_command(cmdargs: List[str], stdin: Optional[bytes] = None,
+ rundir: Optional[str] = None) -> Tuple[int, bytes, bytes]:
+ if rundir:
+ logger.debug('Changing dir to %s', rundir)
+ curdir = os.getcwd()
+ os.chdir(rundir)
+ else:
+ curdir = None
+
logger.debug('Running %s' % ' '.join(cmdargs))
sp = subprocess.Popen(cmdargs, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE)
(output, error) = sp.communicate(input=stdin)
+ if curdir:
+ logger.debug('Changing back into %s', curdir)
+ os.chdir(curdir)
return sp.returncode, output, error
@@ -1846,7 +2099,7 @@ def gpg_run_command(args: List[str], stdin: Optional[bytes] = None) -> Tuple[int
def git_run_command(gitdir: Optional[str], args: List[str], stdin: Optional[bytes] = None,
- logstderr: bool = False) -> Tuple[int, str]:
+ logstderr: bool = False, decode: bool = True) -> Tuple[int, Union[str, bytes]]:
cmdargs = ['git', '--no-pager']
if gitdir:
if os.path.exists(os.path.join(gitdir, '.git')):
@@ -1856,10 +2109,12 @@ def git_run_command(gitdir: Optional[str], args: List[str], stdin: Optional[byte
ecode, out, err = _run_command(cmdargs, stdin=stdin)
- out = out.decode(errors='replace')
+ if decode:
+ out = out.decode(errors='replace')
if logstderr and len(err.strip()):
- err = err.decode(errors='replace')
+ if decode:
+ err = err.decode(errors='replace')
logger.debug('Stderr: %s', err)
out += err
@@ -1878,6 +2133,13 @@ def git_get_command_lines(gitdir: Optional[str], args: list) -> List[str]:
return lines
+def git_get_repo_status(gitdir: Optional[str] = None, untracked: bool = False) -> List[str]:
+ args = ['status', '--porcelain=v1']
+ if not untracked:
+ args.append('--untracked-files=no')
+ return git_get_command_lines(gitdir, args)
+
+
@contextmanager
def git_temp_worktree(gitdir=None, commitish=None):
"""Context manager that creates a temporary work tree and chdirs into it. The
@@ -1893,7 +2155,7 @@ def git_temp_worktree(gitdir=None, commitish=None):
yield dfn
finally:
if dfn is not None:
- git_run_command(gitdir, ['worktree', 'remove', dfn])
+ git_run_command(gitdir, ['worktree', 'remove', '--force', dfn])
@contextmanager
@@ -1926,6 +2188,12 @@ def in_directory(dirname):
os.chdir(cdir)
+def git_set_config(fullpath: Optional[str], param: str, value: str, operation: str = '--replace-all'):
+ args = ['config', operation, param, value]
+ ecode, out = git_run_command(fullpath, args)
+ return ecode
+
+
def get_config_from_git(regexp: str, defaults: Optional[dict] = None, multivals: Optional[list] = None) -> dict:
if multivals is None:
multivals = list()
@@ -1962,9 +2230,6 @@ def get_main_config() -> dict:
config = get_config_from_git(r'b4\..*', defaults=DEFAULT_CONFIG, multivals=['keyringsrc'])
# Legacy name was get-lore-mbox, so load those as well
config = get_config_from_git(r'get-lore-mbox\..*', defaults=config)
- config['trailer-order'] = config['trailer-order'].split(',')
- config['trailer-order'].remove('*')
- config['trailer-order'].append('*')
config['listid-preference'] = config['listid-preference'].split(',')
config['listid-preference'].remove('*')
config['listid-preference'].append('*')
@@ -2073,7 +2338,7 @@ def get_requests_session():
return REQSESSION
-def get_msgid_from_stdin():
+def get_msgid_from_stdin() -> Optional[str]:
if not sys.stdin.isatty():
from email.parser import BytesParser
message = BytesParser().parsebytes(
@@ -2082,7 +2347,7 @@ def get_msgid_from_stdin():
return None
-def get_msgid(cmdargs) -> Optional[str]:
+def get_msgid(cmdargs: argparse.Namespace) -> Optional[str]:
if not cmdargs.msgid:
logger.debug('Getting Message-ID from stdin')
msgid = get_msgid_from_stdin()
@@ -2094,9 +2359,24 @@ def get_msgid(cmdargs) -> Optional[str]:
msgid = msgid.strip('<>')
# Handle the case when someone pastes a full URL to the message
+ # Is this a patchwork URL?
+ matches = re.search(r'^https?://.*/project/.*/patch/([^/]+@[^/]+)', msgid, re.IGNORECASE)
+ if matches:
+ logger.debug('Looks like a patchwork URL')
+ chunks = matches.groups()
+ msgid = urllib.parse.unquote(chunks[0])
+ return msgid
+
+ # Does it look like a public-inbox URL?
matches = re.search(r'^https?://[^/]+/([^/]+)/([^/]+@[^/]+)', msgid, re.IGNORECASE)
if matches:
chunks = matches.groups()
+ config = get_main_config()
+ myloc = urllib.parse.urlparse(config['midmask'])
+ wantloc = urllib.parse.urlparse(msgid)
+ if myloc.netloc != wantloc.netloc:
+ logger.debug('Overriding midmask with passed url parameters')
+ config['midmask'] = f'{wantloc.scheme}://{wantloc.netloc}/{chunks[0]}/%s'
msgid = urllib.parse.unquote(chunks[1])
# Infer the project name from the URL, if possible
if chunks[0] != 'r':
@@ -2108,8 +2388,9 @@ def get_msgid(cmdargs) -> Optional[str]:
return msgid
-def get_strict_thread(msgs, msgid):
+def get_strict_thread(msgs, msgid, noparent=False):
want = {msgid}
+ ignore = set()
got = set()
seen = set()
maybe = dict()
@@ -2117,6 +2398,8 @@ def get_strict_thread(msgs, msgid):
while True:
for msg in msgs:
c_msgid = LoreMessage.get_clean_msgid(msg)
+ if c_msgid in ignore:
+ continue
seen.add(c_msgid)
if c_msgid in got:
continue
@@ -2128,7 +2411,16 @@ def get_strict_thread(msgs, msgid):
msgrefs += email.utils.getaddresses([str(x) for x in msg.get_all('in-reply-to', [])])
if msg.get('References', None):
msgrefs += email.utils.getaddresses([str(x) for x in msg.get_all('references', [])])
+ # If noparent is set, we pretend the message we got passed has no references, and add all
+ # parent references of this message to ignore
+ if noparent and msgid == c_msgid:
+ logger.info('Breaking thread to remove parents of %s', msgid)
+ ignore = set([x[1] for x in msgrefs])
+ msgrefs = list()
+
for ref in set([x[1] for x in msgrefs]):
+ if ref in ignore:
+ continue
if ref in got or ref in want:
want.add(c_msgid)
elif len(ref):
@@ -2166,7 +2458,7 @@ def get_strict_thread(msgs, msgid):
return None
if len(msgs) > len(strict):
- logger.debug('Reduced mbox to strict matches only (%s->%s)', len(msgs), len(strict))
+ logger.debug('Reduced thread to requested matches only (%s->%s)', len(msgs), len(strict))
return strict
@@ -2186,52 +2478,113 @@ def mailsplit_bytes(bmbox: bytes, outdir: str) -> list:
return msgs
-def get_pi_thread_by_url(t_mbx_url, nocache=False):
+def get_pi_search_results(query: str, nocache: bool = False):
+ config = get_main_config()
+ searchmask = config.get('searchmask')
+ if not searchmask:
+ logger.critical('b4.searchmask is not defined')
+ return None
msgs = list()
- cachedir = get_cache_file(t_mbx_url, 'pi.msgs')
+ query = urllib.parse.quote_plus(query)
+ query_url = searchmask % query
+ cachedir = get_cache_file(query_url, 'pi.msgs')
if os.path.exists(cachedir) and not nocache:
logger.debug('Using cached copy: %s', cachedir)
for msg in os.listdir(cachedir):
with open(os.path.join(cachedir, msg), 'rb') as fh:
msgs.append(email.message_from_binary_file(fh))
- else:
- logger.critical('Grabbing thread from %s', t_mbx_url.split('://')[1])
- session = get_requests_session()
- resp = session.get(t_mbx_url)
- if resp.status_code != 200:
- logger.critical('Server returned an error: %s', resp.status_code)
- return None
- t_mbox = gzip.decompress(resp.content)
- resp.close()
- if not len(t_mbox):
- logger.critical('No messages found for that query')
- return None
- # Convert into individual files using git-mailsplit
- with tempfile.TemporaryDirectory(suffix='-mailsplit') as tfd:
- msgs = mailsplit_bytes(t_mbox, tfd)
- if os.path.exists(cachedir):
- shutil.rmtree(cachedir)
- shutil.copytree(tfd, cachedir)
+ return msgs
+
+ loc = urllib.parse.urlparse(query_url)
+ logger.info('Grabbing search results from %s', loc.netloc)
+ session = get_requests_session()
+ # For the query to retrieve a mbox file, we need to send a POST request
+ resp = session.post(query_url, data='')
+ if resp.status_code == 404:
+ logger.info('Nothing matching that query.')
+ return None
+ if resp.status_code != 200:
+ logger.info('Server returned an error: %s', resp.status_code)
+ return None
+ t_mbox = gzip.decompress(resp.content)
+ resp.close()
+ if not len(t_mbox):
+ logger.critical('No messages found for that query')
+ return None
+
+ return split_and_dedupe_pi_results(t_mbox, cachedir=cachedir)
+
+
+def split_and_dedupe_pi_results(t_mbox: bytes, cachedir: Optional[str] = None) -> List[email.message.Message]:
+ # Convert into individual files using git-mailsplit
+ with tempfile.TemporaryDirectory(suffix='-mailsplit') as tfd:
+ msgs = mailsplit_bytes(t_mbox, tfd)
deduped = dict()
+
for msg in msgs:
msgid = LoreMessage.get_clean_msgid(msg)
if msgid in deduped:
deduped[msgid] = LoreMessage.get_preferred_duplicate(deduped[msgid], msg)
continue
deduped[msgid] = msg
- return list(deduped.values())
+
+ msgs = list(deduped.values())
+ if cachedir:
+ if os.path.exists(cachedir):
+ shutil.rmtree(cachedir)
+ pathlib.Path(cachedir).mkdir(parents=True, exist_ok=True)
+ for at, msg in enumerate(msgs):
+ with open(os.path.join(cachedir, '%04d' % at), 'wb') as fh:
+ fh.write(msg.as_bytes())
+
+ return msgs
-def get_pi_thread_by_msgid(msgid, useproject=None, nocache=False, onlymsgids: Optional[set] = None):
+def get_pi_thread_by_url(t_mbx_url: str, nocache: bool = False):
+ msgs = list()
+ cachedir = get_cache_file(t_mbx_url, 'pi.msgs')
+ if os.path.exists(cachedir) and not nocache:
+ logger.debug('Using cached copy: %s', cachedir)
+ for msg in os.listdir(cachedir):
+ with open(os.path.join(cachedir, msg), 'rb') as fh:
+ msgs.append(email.message_from_binary_file(fh))
+ return msgs
+
+ logger.critical('Grabbing thread from %s', t_mbx_url.split('://')[1])
+ session = get_requests_session()
+ resp = session.get(t_mbx_url)
+ if resp.status_code == 404:
+ logger.critical('That message-id is not known.')
+ return None
+ if resp.status_code != 200:
+ logger.critical('Server returned an error: %s', resp.status_code)
+ return None
+ t_mbox = gzip.decompress(resp.content)
+ resp.close()
+ if not len(t_mbox):
+ logger.critical('No messages found for that query')
+ return None
+
+ return split_and_dedupe_pi_results(t_mbox, cachedir=cachedir)
+
+
+def get_pi_thread_by_msgid(msgid: str, useproject: Optional[str] = None, nocache: bool = False,
+ onlymsgids: Optional[set] = None) -> Optional[list]:
qmsgid = urllib.parse.quote_plus(msgid)
config = get_main_config()
- # Grab the head from lore, to see where we are redirected
- midmask = config['midmask'] % qmsgid
- loc = urllib.parse.urlparse(midmask)
+ loc = urllib.parse.urlparse(config['midmask'])
+ # The public-inbox instance may provide a unified index at /all/.
+ # In fact, /all/ naming is arbitrary, but for now we are going to
+ # hardcode it to lore.kernel.org settings and maybe make it configurable
+ # in the future, if necessary.
+ if loc.path.startswith('/all/') and not useproject:
+ useproject = 'all'
if useproject:
projurl = '%s://%s/%s' % (loc.scheme, loc.netloc, useproject)
else:
+ # Grab the head from lore, to see where we are redirected
+ midmask = config['midmask'] % qmsgid
logger.info('Looking up %s', midmask)
session = get_requests_session()
resp = session.head(midmask)
@@ -2264,21 +2617,106 @@ def get_pi_thread_by_msgid(msgid, useproject=None, nocache=False, onlymsgids: Op
return strict
-@contextmanager
-def git_format_patches(gitdir, start, end, prefixes=None, extraopts=None):
- with tempfile.TemporaryDirectory() as tmpd:
- gitargs = ['format-patch', '--cover-letter', '-o', tmpd, '--signature', f'b4 {__VERSION__}']
- if prefixes is not None and len(prefixes):
- gitargs += ['--subject-prefix', ' '.join(prefixes)]
- if extraopts:
- gitargs += extraopts
- gitargs += ['%s..%s' % (start, end)]
- ecode, out = git_run_command(gitdir, gitargs)
+def git_range_to_patches(gitdir: Optional[str], start: str, end: str,
+ covermsg: Optional[email.message.EmailMessage] = None,
+ prefixes: Optional[List[str]] = None,
+ msgid_tpt: Optional[str] = None,
+ seriests: Optional[int] = None,
+ mailfrom: Optional[Tuple[str, str]] = None,
+ extrahdrs: Optional[List[Tuple[str, str]]] = None,
+ ignore_commits: Optional[Set[str]] = None,
+ thread: bool = False,
+ keepdate: bool = False) -> List[Tuple[str, email.message.Message]]:
+ patches = list()
+ commits = git_get_command_lines(gitdir, ['rev-list', '--reverse', f'{start}..{end}'])
+ if not commits:
+ raise RuntimeError(f'Could not run rev-list {start}..{end}')
+ if ignore_commits is None:
+ ignore_commits = set()
+ for commit in commits:
+ if commit in ignore_commits:
+ logger.debug('Ignoring commit %s', commit)
+ continue
+ ecode, out = git_run_command(gitdir, ['show', '--format=email', '--encoding=utf-8', commit], decode=False)
if ecode > 0:
- logger.critical('ERROR: Could not convert pull request into patches')
- logger.critical(out)
- yield None
- yield tmpd
+ raise RuntimeError(f'Could not get a patch out of {commit}')
+ msg = email.message_from_bytes(out)
+ msg.set_charset('utf-8')
+ msg.replace_header('Content-Transfer-Encoding', '8bit')
+ logger.debug(' %s', msg.get('Subject'))
+
+ patches.append((commit, msg))
+
+ startfrom = 1
+ fullcount = len(patches)
+ patches.insert(0, (None, covermsg))
+ if covermsg:
+ startfrom = 0
+
+ # Go through and apply any outstanding fixes
+ if prefixes:
+ prefixes = ' ' + ' '.join(prefixes)
+ else:
+ prefixes = ''
+
+ for counter in range(startfrom, fullcount+1):
+ msg = patches[counter][1]
+ subject = msg.get('Subject')
+ csubject = re.sub(r'^\[PATCH]\s*', '', subject)
+ pline = '[PATCH%s %s/%s]' % (prefixes, str(counter).zfill(len(str(fullcount))), fullcount)
+ msg.replace_header('Subject', f'{pline} {csubject}')
+ inbodyhdrs = list()
+ if mailfrom:
+ # Move the original From and Date into the body
+ origfrom = msg.get('From')
+ if origfrom:
+ origfrom = LoreMessage.clean_header(origfrom)
+ origpair = email.utils.parseaddr(origfrom)
+ if origpair[1] != mailfrom[1]:
+ msg.replace_header('From', format_addrs([mailfrom]))
+ inbodyhdrs.append(f'From: {origfrom}')
+ else:
+ msg.add_header('From', format_addrs([mailfrom]))
+
+ if seriests:
+ patchts = seriests + counter
+ origdate = msg.get('Date')
+ if origdate:
+ if keepdate:
+ inbodyhdrs.append(f'Date: {origdate}')
+ msg.replace_header('Date', email.utils.formatdate(patchts, localtime=True))
+ else:
+ msg.add_header('Date', email.utils.formatdate(patchts, localtime=True))
+
+ payload = msg.get_payload()
+ if inbodyhdrs:
+ payload = '\n'.join(inbodyhdrs) + '\n\n' + payload
+ if not payload.find('\n-- \n') > 0:
+ payload += f'\n-- \nb4 {__VERSION__}\n'
+ msg.set_payload(payload, charset='utf-8')
+
+ if extrahdrs is None:
+ extrahdrs = list()
+ for hdrname, hdrval in extrahdrs:
+ try:
+ msg.replace_header(hdrname, hdrval)
+ except KeyError:
+ msg.add_header(hdrname, hdrval)
+
+ if msgid_tpt:
+ msg.add_header('Message-Id', msgid_tpt % str(counter))
+ refto = None
+ if counter > 0 and covermsg:
+ # Thread to the cover letter
+ refto = msgid_tpt % str(0)
+ if counter > 1 and not covermsg:
+ # Tread to the first patch
+ refto = msgid_tpt % str(1)
+ if refto and thread:
+ msg.add_header('References', refto)
+ msg.add_header('In-Reply-To', refto)
+
+ return patches
def git_commit_exists(gitdir, commit_id):
@@ -2362,16 +2800,16 @@ def check_gpg_status(status: str) -> Tuple[bool, bool, bool, Optional[str], Opti
signtime = None
# Do we have a BADSIG?
- bs_matches = re.search(r'^\[GNUPG:] BADSIG ([0-9A-F]+)\s+(.*)$', status, flags=re.M)
+ bs_matches = re.search(r'^\[GNUPG:] BADSIG ([\dA-F]+)\s+(.*)$', status, flags=re.M)
if bs_matches:
keyid = bs_matches.groups()[0]
return good, valid, trusted, keyid, signtime
- gs_matches = re.search(r'^\[GNUPG:] GOODSIG ([0-9A-F]+)\s+(.*)$', status, flags=re.M)
+ gs_matches = re.search(r'^\[GNUPG:] GOODSIG ([\dA-F]+)\s+(.*)$', status, flags=re.M)
if gs_matches:
good = True
keyid = gs_matches.groups()[0]
- vs_matches = re.search(r'^\[GNUPG:] VALIDSIG ([0-9A-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', status, flags=re.M)
+ vs_matches = re.search(r'^\[GNUPG:] VALIDSIG ([\dA-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', status, flags=re.M)
if vs_matches:
valid = True
signtime = vs_matches.groups()[2]
@@ -2459,3 +2897,341 @@ def get_mailinfo(bmsg: bytes, scissors: bool = False) -> Tuple[dict, bytes, byte
with open(p_out, 'rb') as pfh:
p = pfh.read()
return i, m, p
+
+
+def read_template(tptfile):
+ # bubbles up FileNotFound
+ tpt = ''
+ if tptfile.find('~') >= 0:
+ tptfile = os.path.expanduser(tptfile)
+ if tptfile.find('$') >= 0:
+ tptfile = os.path.expandvars(tptfile)
+ with open(tptfile, 'r', encoding='utf-8') as fh:
+ for line in fh:
+ if len(line) and line[0] == '#':
+ continue
+ tpt += line
+ return tpt
+
+
+def get_smtp(identity: Optional[str] = None,
+ dryrun: bool = False) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, list, None], str]:
+ # Get the default settings first
+ _basecfg = get_config_from_git(r'sendemail\.[^.]+$')
+ if identity:
+ # Use this identity to override what we got from the default one
+ sconfig = get_config_from_git(rf'sendemail\.{identity}\..*', defaults=_basecfg)
+ sectname = f'sendemail.{identity}'
+ else:
+ sconfig = _basecfg
+ sectname = 'sendemail'
+ if not len(sconfig):
+ raise smtplib.SMTPException('Unable to find %s settings in any applicable git config' % sectname)
+
+ # Limited support for smtp settings to begin with, but should cover the vast majority of cases
+ fromaddr = sconfig.get('from')
+ server = sconfig.get('smtpserver', 'localhost')
+ port = sconfig.get('smtpserverport', 0)
+ try:
+ port = int(port)
+ except ValueError:
+ raise smtplib.SMTPException('Invalid smtpport entry in %s' % sectname)
+
+ # If server contains slashes, then it's a local command
+ if '/' in server:
+ server = os.path.expanduser(os.path.expandvars(server))
+ sp = shlex.shlex(server, posix=True)
+ sp.whitespace_split = True
+ smtp = list(sp)
+ return smtp, fromaddr
+
+ encryption = sconfig.get('smtpencryption')
+ if dryrun:
+ return None, fromaddr
+
+ logger.info('Connecting to %s:%s', server, port)
+ # We only authenticate if we have encryption
+ if encryption:
+ if encryption in ('tls', 'starttls'):
+ # We do startssl
+ smtp = smtplib.SMTP(server, port)
+ # Introduce ourselves
+ smtp.ehlo()
+ # Start encryption
+ smtp.starttls()
+ # Introduce ourselves again to get new criteria
+ smtp.ehlo()
+ elif encryption in ('ssl', 'smtps'):
+ # We do TLS from the get-go
+ smtp = smtplib.SMTP_SSL(server, port)
+ else:
+ raise smtplib.SMTPException('Unclear what to do with smtpencryption=%s' % encryption)
+
+ # If we got to this point, we should do authentication.
+ auser = sconfig.get('smtpuser')
+ apass = sconfig.get('smtppass')
+ if auser and apass:
+ # Let any exceptions bubble up
+ smtp.login(auser, apass)
+ else:
+ # We assume you know what you're doing if you don't need encryption
+ smtp = smtplib.SMTP(server, port)
+
+ return smtp, fromaddr
+
+
+def get_patchwork_session(pwkey: str, pwurl: str) -> Tuple[requests.Session, str]:
+ session = requests.session()
+ session.headers.update({
+ 'User-Agent': 'b4/%s' % __VERSION__,
+ 'Authorization': f'Token {pwkey}',
+ })
+ url = '/'.join((pwurl.rstrip('/'), 'api', PW_REST_API_VERSION))
+ logger.debug('pw url=%s', url)
+ return session, url
+
+
+def patchwork_set_state(msgids: List[str], state: str) -> bool:
+ # Do we have a pw-key defined in config?
+ config = get_main_config()
+ pwkey = config.get('pw-key')
+ pwurl = config.get('pw-url')
+ pwproj = config.get('pw-project')
+ if not (pwkey and pwurl and pwproj):
+ logger.debug('Patchwork support not configured')
+ return False
+ pses, url = get_patchwork_session(pwkey, pwurl)
+ patches_url = '/'.join((url, 'patches'))
+ tochange = list()
+ seen = set()
+ for msgid in msgids:
+ if msgid in seen:
+ continue
+ # Two calls, first to look up the patch-id, second to update its state
+ params = [
+ ('project', pwproj),
+ ('archived', 'false'),
+ ('msgid', msgid),
+ ]
+ try:
+ logger.debug('looking up patch_id of msgid=%s', msgid)
+ rsp = pses.get(patches_url, params=params, stream=False)
+ rsp.raise_for_status()
+ pdata = rsp.json()
+ for entry in pdata:
+ patch_id = entry.get('id')
+ if patch_id:
+ title = entry.get('name')
+ if entry.get('state') != state:
+ seen.add(msgid)
+ tochange.append((patch_id, title))
+ except requests.exceptions.RequestException as ex:
+ logger.debug('Patchwork REST error: %s', ex)
+
+ if tochange:
+ logger.info('---')
+ loc = urllib.parse.urlparse(pwurl)
+ logger.info('Patchwork: setting state on %s/%s', loc.netloc, pwproj)
+ for patch_id, title in tochange:
+ patchid_url = '/'.join((patches_url, str(patch_id), ''))
+ logger.debug('patchid_url=%s', patchid_url)
+ data = [
+ ('state', state),
+ ]
+ try:
+ rsp = pses.patch(patchid_url, data=data, stream=False)
+ rsp.raise_for_status()
+ newdata = rsp.json()
+ if newdata.get('state') == state:
+ logger.info(' -> %s : %s', state, title)
+ except requests.exceptions.RequestException as ex:
+ logger.debug('Patchwork REST error: %s', ex)
+
+
+def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, None], msgs: Sequence[email.message.Message],
+ fromaddr: Optional[str], destaddrs: Optional[Union[set, list]] = None,
+ patatt_sign: bool = False, dryrun: bool = False,
+ maxheaderlen: Optional[int] = None, output_dir: Optional[str] = None,
+ use_web_endpoint: bool = False) -> Optional[int]:
+
+ tosend = list()
+ if output_dir is not None:
+ dryrun = True
+ for msg in msgs:
+ if not msg.get('X-Mailer'):
+ msg.add_header('X-Mailer', f'b4 {__VERSION__}')
+ msg.set_charset('utf-8')
+ msg.replace_header('Content-Transfer-Encoding', '8bit')
+ msg.policy = email.policy.EmailPolicy(utf8=True, cte_type='8bit')
+ # Python's sendmail implementation seems to have some logic problems where 8-bit messages are involved.
+ # As far as I understand the difference between 8BITMIME (supported by nearly all smtp servers) and
+ # SMTPUTF8 (supported by very few), SMTPUTF8 is only required when the addresses specified in either
+ # "MAIL FROM" or "RCPT TO" lines of the _protocol exchange_ themselves have 8bit characters, not
+ # anything in the From: header of the DATA payload. Python's smtplib seems to always try to encode
+ # strings as ascii regardless of what was policy was specified.
+ # Work around this by getting the payload as string and then encoding to bytes ourselves.
+ if maxheaderlen is None:
+ if dryrun:
+ # Make it fit the terminal window, but no wider than 120 minus visual padding
+ ts = shutil.get_terminal_size((120, 20))
+ maxheaderlen = ts.columns - 8
+ if maxheaderlen > 112:
+ maxheaderlen = 112
+ else:
+ # Use a sane-ish default (we don't need to stick to 80, but
+ # we need to make sure it's shorter than 255)
+ maxheaderlen = 120
+
+ emldata = msg.as_string(maxheaderlen=maxheaderlen)
+ bdata = emldata.encode()
+ subject = msg.get('Subject', '')
+ ls = LoreSubject(subject)
+ if patatt_sign:
+ import patatt
+ # patatt.logger = logger
+ bdata = patatt.rfc2822_sign(bdata)
+ if dryrun:
+ if output_dir:
+ filen = '%s.eml' % ls.get_slug(sep='-')
+ logger.info(' %s', filen)
+ write_to = os.path.join(output_dir, filen)
+ with open(write_to, 'wb') as fh:
+ fh.write(bdata)
+ continue
+ logger.info(' --- DRYRUN: message follows ---')
+ logger.info(' | ' + bdata.decode().rstrip().replace('\n', '\n | '))
+ logger.info(' --- DRYRUN: message ends ---')
+ continue
+ if not destaddrs:
+ alldests = email.utils.getaddresses([str(x) for x in msg.get_all('to', [])])
+ alldests += email.utils.getaddresses([str(x) for x in msg.get_all('cc', [])])
+ destaddrs = {x[1] for x in alldests}
+
+ tosend.append((destaddrs, bdata, ls))
+
+ if not len(tosend):
+ return 0
+
+ logger.info('---')
+ # Do we have an endpoint defined?
+ config = get_main_config()
+ endpoint = config.get('send-endpoint-web')
+ if use_web_endpoint and endpoint:
+ logger.info('Sending via web endpoint %s', endpoint)
+ req = {
+ 'action': 'receive',
+ 'messages': [x[1].decode() for x in tosend],
+ }
+ ses = get_requests_session()
+ res = ses.post(endpoint, json=req)
+ try:
+ rdata = res.json()
+ if rdata.get('result') == 'success':
+ return len(tosend)
+ except Exception as ex: # noqa
+ logger.critical('Odd response from the endpoint: %s', res.text)
+ return 0
+
+ if rdata.get('result') == 'error':
+ logger.critical('Error from endpoint: %s', rdata.get('message'))
+ return 0
+
+ sent = 0
+ if isinstance(smtp, list):
+ # This is a local command
+ logger.info('Sending via "%s"', ' '.join(smtp))
+ for destaddrs, bdata, lsubject in tosend:
+ logger.info(' %s', lsubject.full_subject)
+ ecode, out, err = _run_command(smtp, stdin=bdata)
+ if ecode > 0:
+ raise RuntimeError('Error running %s: %s' % (' '.join(smtp), err.decode()))
+ sent += 1
+
+ elif smtp:
+ for destaddrs, bdata, lsubject in tosend:
+ # Force compliant eols
+ bdata = re.sub(rb'\r\n|\n|\r(?!\n)', b'\r\n', bdata)
+ logger.info(' %s', lsubject.full_subject)
+ smtp.sendmail(fromaddr, destaddrs, bdata)
+ sent += 1
+
+ return sent
+
+
+def git_get_current_branch(gitdir: Optional[str] = None, short: bool = True) -> Optional[str]:
+ gitargs = ['symbolic-ref', '-q', 'HEAD']
+ ecode, out = git_run_command(gitdir, gitargs)
+ if ecode > 0:
+ logger.critical('Not able to get current branch (git symbolic-ref HEAD)')
+ return None
+ mybranch = out.strip()
+ if short:
+ return re.sub(r'^refs/heads/', '', mybranch)
+ return mybranch
+
+
+def get_excluded_addrs() -> Set[str]:
+ config = get_main_config()
+ excludes = set()
+ c_excludes = config.get('email-exclude')
+ if c_excludes:
+ for entry in c_excludes.split(','):
+ excludes.add(entry.strip())
+
+ return excludes
+
+
+def cleanup_email_addrs(addresses: List[Tuple[str, str]], excludes: Set[str],
+ gitdir: Optional[str]) -> List[Tuple[str, str]]:
+ global MAILMAP_INFO
+ for entry in list(addresses):
+ # Only qualified addresses, please
+ if not len(entry[1].strip()) or '@' not in entry[1]:
+ addresses.remove(entry)
+ continue
+ # Check if it's in excludes
+ removed = False
+ for exclude in excludes:
+ if fnmatch.fnmatch(entry[1], exclude):
+ logger.debug('Removed %s due to matching %s', entry[1], exclude)
+ addresses.remove(entry)
+ removed = True
+ break
+ if removed:
+ continue
+ # Check if it's mailmap-replaced
+ if entry[1] in MAILMAP_INFO:
+ if MAILMAP_INFO[entry[1]]:
+ addresses.remove(entry)
+ addresses.append(MAILMAP_INFO[entry[1]])
+ continue
+ logger.debug('Checking if %s is mailmap-replaced', entry[1])
+ args = ['check-mailmap', f'<{entry[1]}>']
+ ecode, out = git_run_command(gitdir, args)
+ if ecode != 0:
+ MAILMAP_INFO[entry[1]] = None
+ continue
+ replacement = email.utils.getaddresses([out.strip()])
+ if len(replacement) == 1:
+ if entry[1] == replacement[0][1]:
+ MAILMAP_INFO[entry[1]] = None
+ continue
+ logger.debug('Replaced %s with mailmap-updated %s', entry[1], replacement[0][1])
+ MAILMAP_INFO[entry[1]] = replacement[0]
+ addresses.remove(entry)
+ addresses.append(replacement[0])
+
+ return addresses
+
+
+def get_email_signature() -> str:
+ usercfg = get_user_config()
+ # Do we have a .signature file?
+ sigfile = os.path.join(str(Path.home()), '.signature')
+ if os.path.exists(sigfile):
+ with open(sigfile, 'r', encoding='utf-8') as fh:
+ signature = fh.read()
+ else:
+ signature = '%s <%s>' % (usercfg['name'], usercfg['email'])
+
+ return signature
diff --git a/b4/attest.py b/b4/attest.py
deleted file mode 100644
index a6acb28..0000000
--- a/b4/attest.py
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/usr/bin/env python3
-# -*- coding: utf-8 -*-
-# SPDX-License-Identifier: GPL-2.0-or-later
-# Copyright (C) 2020 by the Linux Foundation
-#
-
-import sys
-import b4
-import argparse
-try:
- import patatt
- can_patatt = True
-except ModuleNotFoundError:
- can_patatt = False
-
-from collections import namedtuple
-
-logger = b4.logger
-
-
-def attest_patches(cmdargs: argparse.Namespace) -> None:
- if not can_patatt:
- logger.critical('ERROR: b4 now uses patatt for patch attestation. See:')
- logger.critical(' https://git.kernel.org/pub/scm/utils/patatt/patatt.git/about/')
- sys.exit(1)
-
- # directly invoke cmd_sign in patatt
- config = patatt.get_config_from_git(r'patatt\..*', multivals=['keyringsrc'])
- fakeargs = namedtuple('Struct', ['hookmode', 'msgfile'])
- fakeargs.hookmode = True
- fakeargs.msgfile = cmdargs.patchfile
- patatt.cmd_sign(fakeargs, config)
diff --git a/b4/command.py b/b4/command.py
index 5bb3384..5cd4425 100644
--- a/b4/command.py
+++ b/b4/command.py
@@ -17,7 +17,7 @@ def cmd_retrieval_common_opts(sp):
sp.add_argument('msgid', nargs='?',
help='Message ID to process, or pipe a raw message')
sp.add_argument('-p', '--use-project', dest='useproject', default=None,
- help='Use a specific project instead of guessing (linux-mm, linux-hardening, etc)')
+ help='Use a specific project instead of default (linux-mm, linux-hardening, etc)')
sp.add_argument('-m', '--use-local-mbox', dest='localmbox', default=None,
help='Instead of grabbing a thread from lore, process this mbox file (or - for stdin)')
sp.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False,
@@ -36,6 +36,31 @@ def cmd_mbox_common_opts(sp):
help='Save as maildir (avoids mbox format ambiguities)')
+def cmd_am_common_opts(sp):
+ sp.add_argument('-v', '--use-version', dest='wantver', type=int, default=None,
+ help='Get a specific version of the patch/series')
+ sp.add_argument('-t', '--apply-cover-trailers', dest='covertrailers', action='store_true', default=False,
+ help='Apply trailers sent to the cover letter to all patches')
+ sp.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False,
+ help='Apply trailers without email address match checking')
+ sp.add_argument('-T', '--no-add-trailers', dest='noaddtrailers', action='store_true', default=False,
+ help='Do not add any trailers from follow-up messages')
+ sp.add_argument('-s', '--add-my-sob', dest='addmysob', action='store_true', default=False,
+ help='Add your own signed-off-by to every patch')
+ sp.add_argument('-l', '--add-link', dest='addlink', action='store_true', default=False,
+ help='Add a Link: with message-id lookup URL to every patch')
+ sp.add_argument('-P', '--cherry-pick', dest='cherrypick', default=None,
+ help='Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", '
+ '"-P _" to use just the msgid specified, or '
+ '"-P *globbing*" to match on commit subject)')
+ sp.add_argument('--cc-trailers', dest='copyccs', action='store_true', default=False,
+ help='Copy all Cc\'d addresses into Cc: trailers')
+ sp.add_argument('--no-parent', dest='noparent', action='store_true', default=False,
+ help='Break thread at the msgid specified and ignore any parent messages')
+ sp.add_argument('--allow-unicode-control-chars', dest='allowbadchars', action='store_true', default=False,
+ help='Allow unicode control characters (very rarely legitimate)')
+
+
def cmd_mbox(cmdargs):
import b4.mbox
b4.mbox.main(cmdargs)
@@ -46,18 +71,29 @@ def cmd_kr(cmdargs):
b4.kr.main(cmdargs)
+def cmd_prep(cmdargs):
+ import b4.ez
+ b4.ez.cmd_prep(cmdargs)
+
+
+def cmd_trailers(cmdargs):
+ import b4.ez
+ b4.ez.cmd_trailers(cmdargs)
+
+
+def cmd_send(cmdargs):
+ import b4.ez
+ b4.ez.cmd_send(cmdargs)
+
+
def cmd_am(cmdargs):
import b4.mbox
b4.mbox.main(cmdargs)
-def cmd_attest(cmdargs):
- import b4.attest
- if len(cmdargs.patchfile):
- b4.attest.attest_patches(cmdargs)
- else:
- logger.critical('ERROR: missing patches to attest')
- sys.exit(1)
+def cmd_shazam(cmdargs):
+ import b4.mbox
+ b4.mbox.main(cmdargs)
def cmd_pr(cmdargs):
@@ -87,6 +123,8 @@ def cmd():
help='Add more debugging info to the output')
parser.add_argument('-q', '--quiet', action='store_true', default=False,
help='Output critical information only')
+ parser.add_argument('-n', '--no-interactive', action='store_true', default=False,
+ help='Do not ask any interactive questions')
subparsers = parser.add_subparsers(help='sub-command help', dest='subcmd')
@@ -100,53 +138,37 @@ def cmd():
# b4 am
sp_am = subparsers.add_parser('am', help='Create an mbox file that is ready to git-am')
cmd_mbox_common_opts(sp_am)
- sp_am.add_argument('-v', '--use-version', dest='wantver', type=int, default=None,
- help='Get a specific version of the patch/series')
- sp_am.add_argument('-t', '--apply-cover-trailers', dest='covertrailers', action='store_true', default=False,
- help='Apply trailers sent to the cover letter to all patches')
- sp_am.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False,
- help='Apply trailers without email address match checking')
- sp_am.add_argument('-T', '--no-add-trailers', dest='noaddtrailers', action='store_true', default=False,
- help='Do not add or sort any trailers')
- sp_am.add_argument('-s', '--add-my-sob', dest='addmysob', action='store_true', default=False,
- help='Add your own signed-off-by to every patch')
- sp_am.add_argument('-l', '--add-link', dest='addlink', action='store_true', default=False,
- help='Add a lore.kernel.org/r/ link to every patch')
+ cmd_am_common_opts(sp_am)
sp_am.add_argument('-Q', '--quilt-ready', dest='quiltready', action='store_true', default=False,
help='Save patches in a quilt-ready folder')
- sp_am.add_argument('-P', '--cherry-pick', dest='cherrypick', default=None,
- help='Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", '
- '"-P _" to use just the msgid specified, or '
- '"-P *globbing*" to match on commit subject)')
sp_am.add_argument('-g', '--guess-base', dest='guessbase', action='store_true', default=False,
help='Try to guess the base of the series (if not specified)')
- sp_am.add_argument('-b', '--guess-branch', dest='guessbranch', default=None,
+ sp_am.add_argument('-b', '--guess-branch', dest='guessbranch', nargs='+', action='extend', type=str, default=None,
help='When guessing base, restrict to this branch (use with -g)')
- sp_am.add_argument('--guess-lookback', dest='guessdays', type=int, default=14,
- help='When guessing base, go back this many days from the date of the patch')
+ sp_am.add_argument('--guess-lookback', dest='guessdays', type=int, default=21,
+ help='When guessing base, go back this many days from the patch date (default: 2 weeks)')
sp_am.add_argument('-3', '--prep-3way', dest='threeway', action='store_true', default=False,
help='Prepare for a 3-way merge '
'(tries to ensure that all index blobs exist by making a fake commit range)')
- sp_am.add_argument('--cc-trailers', dest='copyccs', action='store_true', default=False,
- help='Copy all Cc\'d addresses into Cc: trailers')
sp_am.add_argument('--no-cover', dest='nocover', action='store_true', default=False,
help='Do not save the cover letter (on by default when using -o -)')
sp_am.add_argument('--no-partial-reroll', dest='nopartialreroll', action='store_true', default=False,
help='Do not reroll partial series when detected')
sp_am.set_defaults(func=cmd_am)
- # b4 attest
- sp_att = subparsers.add_parser('attest', help='Create cryptographic attestation for a set of patches')
- sp_att.add_argument('-f', '--from', dest='sender', default=None,
- help='OBSOLETE: this option does nothing and will be removed')
- sp_att.add_argument('-n', '--no-submit', dest='nosubmit', action='store_true', default=False,
- help='OBSOLETE: this option does nothing and will be removed')
- sp_att.add_argument('-o', '--output', default=None,
- help='OBSOLETE: this option does nothing and will be removed')
- sp_att.add_argument('-m', '--mutt-filter', default=None,
- help='OBSOLETE: this option does nothing and will be removed')
- sp_att.add_argument('patchfile', nargs='*', help='Patches to attest')
- sp_att.set_defaults(func=cmd_attest)
+ # b4 shazam
+ sp_sh = subparsers.add_parser('shazam', help='Like b4 am, but applies the series to your tree')
+ cmd_retrieval_common_opts(sp_sh)
+ cmd_am_common_opts(sp_sh)
+ sh_g = sp_sh.add_mutually_exclusive_group()
+ sh_g.add_argument('-H', '--make-fetch-head', dest='makefetchhead', action='store_true', default=False,
+ help='Attempt to treat series as a pull request and fetch it into FETCH_HEAD')
+ sh_g.add_argument('-M', '--merge', dest='merge', action='store_true', default=False,
+ help='Attempt to merge series as if it were a pull request (execs git-merge)')
+ sp_sh.add_argument('--guess-lookback', dest='guessdays', type=int, default=21,
+ help=('(use with -H or -M) When guessing base, go back this many days from the patch date '
+ '(default: 3 weeks)'))
+ sp_sh.set_defaults(func=cmd_shazam)
# b4 pr
sp_pr = subparsers.add_parser('pr', help='Fetch a pull request found in a message ID')
@@ -181,7 +203,7 @@ def cmd():
help='Write thanks files into this dir (default=.)')
sp_ty.add_argument('-l', '--list', action='store_true', default=False,
help='List pull requests and patch series you have retrieved')
- sp_ty.add_argument('-s', '--send', default=None,
+ sp_ty.add_argument('-t', '--thank-for', dest='thankfor', default=None,
help='Generate thankyous for specific entries from -l (e.g.: 1,3-5,7-; or "all")')
sp_ty.add_argument('-d', '--discard', default=None,
help='Discard specific messages from -l (e.g.: 1,3-5,7-; or "all")')
@@ -191,6 +213,12 @@ def cmd():
help='The branch to check against, instead of current')
sp_ty.add_argument('--since', default='1.week',
help='The --since option to use when auto-matching patches (default=1.week)')
+ sp_ty.add_argument('-S', '--send-email', action='store_true', dest='sendemail', default=False,
+ help='Send email instead of writing out .thanks files')
+ sp_ty.add_argument('--dry-run', action='store_true', dest='dryrun', default=False,
+ help='Print out emails instead of sending them')
+ sp_ty.add_argument('--pw-set-state', default=None,
+ help='Set this patchwork state instead of default (use with -a, -t or -d)')
sp_ty.set_defaults(func=cmd_ty)
# b4 diff
@@ -200,7 +228,7 @@ def cmd():
sp_diff.add_argument('-g', '--gitdir', default=None,
help='Operate on this git tree instead of current dir')
sp_diff.add_argument('-p', '--use-project', dest='useproject', default=None,
- help='Use a specific project instead of guessing (linux-mm, linux-hardening, etc)')
+ help='Use a specific project instead of default (linux-mm, linux-hardening, etc)')
sp_diff.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False,
help='Do not use local cache')
sp_diff.add_argument('-v', '--compare-versions', dest='wantvers', type=int, default=None, nargs='+',
@@ -222,6 +250,65 @@ def cmd():
help='Show all developer keys found in a thread')
sp_kr.set_defaults(func=cmd_kr)
+ # b4 prep
+ sp_prep = subparsers.add_parser('prep', help='Work on patch series to submit for mailing list review')
+ sp_prep.add_argument('--edit-cover', action='store_true', default=False,
+ help='Edit the cover letter in your defined $EDITOR (or core.editor)')
+ sp_prep.add_argument('--show-revision', action='store_true', default=False,
+ help='Show current series revision number')
+ sp_prep.add_argument('--force-revision', default=False, metavar='N', type=int,
+ help='Force revision to be this number instead')
+ sp_prep.add_argument('--format-patch', metavar='OUTPUT_DIR',
+ help='Output prep-tracked commits as patches')
+ ag_prepn = sp_prep.add_argument_group('Create new branch', 'Create a new branch for working on patch series')
+ ag_prepn.add_argument('-n', '--new', dest='new_series_name',
+ help='Create a new branch for working on a patch series')
+ ag_prepn.add_argument('-f', '--fork-point', dest='fork_point',
+ help='When creating a new branch, use this fork point instead of HEAD')
+ ag_prepn.add_argument('-F', '--from-thread', metavar='MSGID', dest='msgid',
+ help='When creating a new branch, use this thread')
+ ag_prepe = sp_prep.add_argument_group('Enroll existing branch', 'Enroll existing branch for prep work')
+ ag_prepe.add_argument('-e', '--enroll', dest='enroll_base',
+ help='Enroll current branch, using the passed tag, branch, or commit as fork base')
+ sp_prep.set_defaults(func=cmd_prep)
+
+ # b4 trailers
+ sp_trl = subparsers.add_parser('trailers', help='Operate on trailers received for mailing list reviews')
+ sp_trl.add_argument('-u', '--update', action='store_true', default=False,
+ help='Update branch commits with latest received trailers')
+ sp_trl.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False,
+ help='Apply trailers without email address match checking')
+ sp_trl.add_argument('-F', '--trailers-from', dest='msgid',
+ help='Look for trailers in the thread with this msgid instead of using the series change-id')
+ sp_trl.add_argument('--since', default='1.month',
+ help='The --since option to use with -F when auto-matching patches (default=1.month)')
+ sp_trl.set_defaults(func=cmd_trailers)
+
+ # b4 send
+ sp_send = subparsers.add_parser('send', help='Submit your work for review on the mailing lists')
+ sp_send.add_argument('-d', '--dry-run', dest='dryrun', action='store_true', default=False,
+ help='Do not send, just dump out raw smtp messages to the stdout')
+ sp_send.add_argument('-o', '--output-dir',
+ help='Do not send, write raw messages to this directory (forces --dry-run)')
+ sp_send.add_argument('--prefixes', nargs='+',
+ help='Prefixes to add to PATCH (e.g. RFC, WIP)')
+ sp_send.add_argument('--no-auto-to-cc', action='store_true', default=False,
+ help='Do not automatically collect To: and Cc: addresses')
+ sp_send.add_argument('--to', nargs='+', help='Addresses to add to the To: list')
+ sp_send.add_argument('--cc', nargs='+', help='Addresses to add to the Cc: list')
+ sp_send.add_argument('--not-me-too', action='store_true', default=False,
+ help='Remove yourself from the To: or Cc: list')
+ sp_send.add_argument('--resend', default=None,
+ help='Resend a previously sent version of the series')
+ sp_send.add_argument('--no-sign', action='store_true', default=False,
+ help='Do not cryptographically sign your patches with patatt')
+ ag_sendh = sp_send.add_argument_group('Web submission', 'Authenticate with the web submission endpoint')
+ ag_sendh.add_argument('--web-auth-new', dest='auth_new', action='store_true', default=False,
+ help='Initiate a new web authentication request')
+ ag_sendh.add_argument('--web-auth-verify', dest='auth_verify', metavar='VERIFY_TOKEN',
+ help='Submit the token received via verification email')
+ sp_send.set_defaults(func=cmd_send)
+
cmdargs = parser.parse_args()
logger.setLevel(logging.DEBUG)
diff --git a/b4/ez.py b/b4/ez.py
new file mode 100644
index 0000000..593e9a7
--- /dev/null
+++ b/b4/ez.py
@@ -0,0 +1,1461 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# SPDX-License-Identifier: GPL-2.0-or-later
+# Copyright (C) 2020 by the Linux Foundation
+#
+__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
+
+import email.message
+import os
+import sys
+import b4
+import re
+import argparse
+import uuid
+import time
+import datetime
+import json
+import tempfile
+import subprocess
+import shlex
+import email
+import pathlib
+import base64
+import textwrap
+import gzip
+import io
+
+from typing import Optional, Tuple, List
+from email import utils
+from string import Template
+
+try:
+ import patatt
+ can_patatt = True
+except ModuleNotFoundError:
+ can_patatt = False
+
+try:
+ import git_filter_repo as fr # noqa
+ can_gfr = True
+except ModuleNotFoundError:
+ can_gfr = False
+
+logger = b4.logger
+
+MAGIC_MARKER = '--- b4-submit-tracking ---'
+
+DEFAULT_COVER_TEMPLATE = """
+${cover}
+
+---
+${shortlog}
+
+${diffstat}
+---
+base-commit: ${base_commit}
+change-id: ${change_id}
+
+Best regards,
+--
+${signature}
+"""
+
+DEFAULT_CHANGELOG_TEMPLATE = """
+Changes in v${newrev}:
+- EDITME: describe what is new in this series revision.
+- EDITME: use bulletpoints and terse descriptions.
+- Link to v${oldrev}: ${oldrev_link}
+
+"""
+
+
+def get_auth_configs() -> Tuple[str, str, str, str, str, str]:
+ config = b4.get_main_config()
+ endpoint = config.get('send-endpoint-web')
+ if not endpoint:
+ raise RuntimeError('No web submission endpoint defined, set b4.send-endpoint-web')
+
+ usercfg = b4.get_user_config()
+ myemail = usercfg.get('email')
+ if not myemail:
+ raise RuntimeError('No email configured, set user.email')
+ myname = usercfg.get('name')
+ pconfig = patatt.get_main_config()
+ selector = pconfig.get('selector', 'default')
+ algo, keydata = patatt.get_algo_keydata(pconfig)
+ return endpoint, myname, myemail, selector, algo, keydata
+
+
+def auth_new() -> None:
+ try:
+ endpoint, myname, myemail, selector, algo, keydata = get_auth_configs()
+ except patatt.NoKeyError as ex:
+ logger.critical('CRITICAL: no usable signing key configured')
+ logger.critical(' %s', ex)
+ sys.exit(1)
+ except RuntimeError as ex:
+ logger.critical('CRITICAL: unable to set up web authentication')
+ logger.critical(' %s', ex)
+ sys.exit(1)
+
+ if algo == 'openpgp':
+ gpgargs = ['--export', '--export-options', 'export-minimal', '-a', keydata]
+ ecode, out, err = b4.gpg_run_command(gpgargs)
+ if ecode > 0:
+ logger.critical('CRITICAL: unable to get PGP public key for %s:%s', algo, keydata)
+ sys.exit(1)
+ pubkey = out.decode()
+ elif algo == 'ed25519':
+ from nacl.signing import SigningKey
+ from nacl.encoding import Base64Encoder
+ sk = SigningKey(keydata, encoder=Base64Encoder)
+ pubkey = base64.b64encode(sk.verify_key.encode()).decode()
+ else:
+ logger.critical('CRITICAL: algorithm %s not currently supported for web endpoint submission', algo)
+ sys.exit(1)
+
+ logger.info('Will submit a new email authorization request to:')
+ logger.info(' Endpoint: %s', endpoint)
+ logger.info(' Name: %s', myname)
+ logger.info(' Identity: %s', myemail)
+ logger.info(' Selector: %s', selector)
+ if algo == 'openpgp':
+ logger.info(' Pubkey: %s:%s', algo, keydata)
+ else:
+ logger.info(' Pubkey: %s:%s', algo, pubkey)
+ logger.info('---')
+ try:
+ input('Press Enter to confirm or Ctrl-C to abort')
+ except KeyboardInterrupt:
+ logger.info('')
+ sys.exit(130)
+
+ req = {
+ 'action': 'auth-new',
+ 'name': myname,
+ 'identity': myemail,
+ 'selector': selector,
+ 'pubkey': pubkey,
+ }
+ logger.info('Submitting new auth request to %s', endpoint)
+ ses = b4.get_requests_session()
+ res = ses.post(endpoint, json=req)
+ logger.info('---')
+ if res.status_code == 200:
+ try:
+ rdata = res.json()
+ if rdata.get('result') == 'success':
+ logger.info('Challenge generated and sent to %s', myemail)
+ logger.info('Once you receive it, run b4 send --web-auth-verify [challenge-string]')
+ sys.exit(0)
+
+ except Exception as ex: # noqa
+ logger.critical('Odd response from the endpoint: %s', res.text)
+ sys.exit(1)
+
+ logger.critical('500 response from the endpoint: %s', res.text)
+ sys.exit(1)
+
+
+def auth_verify(cmdargs: argparse.Namespace) -> None:
+ vstr = cmdargs.auth_verify
+ endpoint, myname, myemail, selector, algo, keydata = get_auth_configs()
+ logger.info('Signing challenge')
+ # Create a minimal message
+ cmsg = email.message.EmailMessage()
+ cmsg.add_header('From', myemail)
+ cmsg.add_header('Subject', 'b4-send-verify')
+ cmsg.set_payload(f'verify:{vstr}\n')
+ bdata = cmsg.as_bytes(policy=b4.emlpolicy)
+ try:
+ bdata = patatt.rfc2822_sign(bdata).decode()
+ except patatt.SigningError as ex:
+ logger.critical('CRITICAL: Unable to sign verification message')
+ logger.critical(' %s', ex)
+ sys.exit(1)
+
+ req = {
+ 'action': 'auth-verify',
+ 'msg': bdata.encode(),
+ }
+ logger.info('Submitting verification to %s', endpoint)
+ ses = b4.get_requests_session()
+ res = ses.post(endpoint, json=req)
+ logger.info('---')
+ if res.status_code == 200:
+ try:
+ rdata = res.json()
+ if rdata.get('result') == 'success':
+ logger.info('Challenge successfully verified for %s', myemail)
+ logger.info('You may now use this endpoint for submitting patches.')
+ sys.exit(0)
+
+ except Exception as ex: # noqa
+ logger.critical('Odd response from the endpoint: %s', res.text)
+ sys.exit(1)
+
+ logger.critical('500 response from the endpoint: %s', res.text)
+ sys.exit(1)
+
+
+def get_rev_count(revrange: str, maxrevs: Optional[int] = 500) -> int:
+ # Check how many revisions there are between the fork-point and the current HEAD
+ gitargs = ['rev-list', revrange]
+ lines = b4.git_get_command_lines(None, gitargs)
+ # Check if this range is too large, if requested
+ if maxrevs and len(lines) > maxrevs:
+ raise RuntimeError('Too many commits in the range provided: %s' % len(lines))
+ return len(lines)
+
+
+def get_base_forkpoint(basebranch: str, mybranch: Optional[str] = None) -> str:
+ if mybranch is None:
+ mybranch = b4.git_get_current_branch()
+ logger.debug('Finding the fork-point with %s', basebranch)
+ gitargs = ['merge-base', '--fork-point', basebranch]
+ lines = b4.git_get_command_lines(None, gitargs)
+ if not lines:
+ logger.critical('CRITICAL: Could not find common ancestor with %s', basebranch)
+ raise RuntimeError('Branches %s and %s have no common ancestors' % (basebranch, mybranch))
+ forkpoint = lines[0]
+ logger.debug('Fork-point between %s and %s is %s', mybranch, basebranch, forkpoint)
+
+ return forkpoint
+
+
+def start_new_series(cmdargs: argparse.Namespace) -> None:
+ usercfg = b4.get_user_config()
+ if 'name' not in usercfg or 'email' not in usercfg:
+ logger.critical('CRITICAL: Unable to add your Signed-off-by: git returned no user.name or user.email')
+ sys.exit(1)
+
+ cover = tracking = patches = thread_msgid = revision = None
+ if cmdargs.msgid:
+ msgid = b4.get_msgid(cmdargs)
+ list_msgs = b4.get_pi_thread_by_msgid(msgid)
+ if not list_msgs:
+ logger.critical('CRITICAL: no messages in the thread')
+ sys.exit(1)
+ lmbx = b4.LoreMailbox()
+ for msg in list_msgs:
+ lmbx.add_message(msg)
+ lser = lmbx.get_series()
+ if lser.has_cover:
+ cmsg = lser.patches[0]
+ b64tracking = cmsg.msg.get('x-b4-tracking')
+ if b64tracking:
+ logger.debug('Found x-b4-tracking header, attempting to restore')
+ try:
+ ztracking = base64.b64decode(b64tracking)
+ btracking = gzip.decompress(ztracking)
+ tracking = json.loads(btracking.decode())
+ logger.debug('tracking: %s', tracking)
+ cover_sections = list()
+ diffstatre = re.compile(r'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.M | re.I)
+ for section in cmsg.body.split('\n---\n'):
+ # we stop caring once we see a diffstat
+ if diffstatre.search(section):
+ break
+ cover_sections.append(section)
+ cover = '\n---\n'.join(cover_sections).strip()
+ except Exception as ex: # noqa
+ logger.critical('CRITICAL: unable to restore tracking information, ignoring')
+ logger.critical(' %s', ex)
+
+ else:
+ thread_msgid = msgid
+
+ if not cover:
+ logger.debug('Unrecognized cover letter format, will use as-is')
+ cover = cmsg.body
+
+ cover = (f'{cmsg.subject}\n\n'
+ f'EDITME: Imported from f{msgid}\n'
+ f' Please review before sending.\n\n') + cover
+
+ change_id = lser.change_id
+ if not cmdargs.new_series_name:
+ if change_id:
+ cchunks = change_id.split('-')
+ if len(cchunks) > 2:
+ cmdargs.new_series_name = '-'.join(cchunks[1:-1])
+ else:
+ slug = cmsg.lsubject.get_slug(with_counter=False)
+ # If it's longer than 30 chars, use first 3 words
+ if len(slug) > 30:
+ slug = '_'.join(slug.split('_')[:3])
+ cmdargs.new_series_name = slug
+
+ base_commit = lser.base_commit
+ if base_commit and not cmdargs.fork_point:
+ logger.debug('Using %s as fork-point', base_commit)
+ cmdargs.fork_point = base_commit
+
+ # We start with next revision
+ revision = lser.revision + 1
+ # Do or don't add follow-up trailers? Don't add for now, let them run b4 trailers -u.
+ patches = lser.get_am_ready(noaddtrailers=True)
+ logger.info('---')
+
+ mybranch = b4.git_get_current_branch()
+ strategy = get_cover_strategy()
+ cherry_range = None
+ if cmdargs.new_series_name:
+ basebranch = None
+ if not cmdargs.fork_point:
+ cmdargs.fork_point = 'HEAD'
+ else:
+ # if our strategy is not "commit", then we need to know which branch we're using as base
+ if strategy != 'commit':
+ gitargs = ['branch', '-v', '--contains', cmdargs.fork_point]
+ lines = b4.git_get_command_lines(None, gitargs)
+ if not lines:
+ logger.critical('CRITICAL: no branch contains fork-point %s', cmdargs.fork_point)
+ sys.exit(1)
+ for line in lines:
+ chunks = line.split(maxsplit=2)
+ # There's got to be a better way than checking for '*'
+ if chunks[0] != '*':
+ continue
+ if chunks[1] == mybranch:
+ logger.debug('branch %s does contain fork-point %s', mybranch, cmdargs.fork_point)
+ basebranch = mybranch
+ break
+ if basebranch is None:
+ logger.critical('CRITICAL: fork-point %s is not on the current branch.')
+ logger.critical(' Switch to the branch you want to use as base and try again.')
+ sys.exit(1)
+
+ slug = re.sub(r'\W+', '-', cmdargs.new_series_name).strip('-').lower()
+ branchname = 'b4/%s' % slug
+ args = ['checkout', '-b', branchname, cmdargs.fork_point]
+ ecode, out = b4.git_run_command(None, args, logstderr=True)
+ if ecode > 0:
+ logger.critical('CRITICAL: Failed to create a new branch %s', branchname)
+ logger.critical(out)
+ sys.exit(ecode)
+ logger.info('Created new branch %s', branchname)
+ seriesname = cmdargs.new_series_name
+
+ elif cmdargs.enroll_base:
+ basebranch = None
+ branchname = b4.git_get_current_branch()
+ seriesname = branchname
+ slug = re.sub(r'\W+', '-', branchname).strip('-').lower()
+ enroll_base = cmdargs.enroll_base
+ # Is it a branch?
+ gitargs = ['show-ref', '--heads', enroll_base]
+ lines = b4.git_get_command_lines(None, gitargs)
+ if lines:
+ try:
+ forkpoint = get_base_forkpoint(enroll_base, mybranch)
+ except RuntimeError as ex:
+ logger.critical('CRITICAL: could not use %s as enrollment base:')
+ logger.critical(' %s', ex)
+ sys.exit(1)
+ basebranch = enroll_base
+ else:
+ # Check that that object exists
+ gitargs = ['rev-parse', '--verify', enroll_base]
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ logger.critical('CRITICAL: Could not find object: %s', enroll_base)
+ raise RuntimeError('Object %s not found' % enroll_base)
+ forkpoint = out.strip()
+ # check branches where this object lives
+ heads = b4.git_branch_contains(None, forkpoint)
+ if mybranch not in heads:
+ logger.critical('CRITICAL: object %s does not exist on current branch', enroll_base)
+ sys.exit(1)
+ if strategy != 'commit':
+ # Remove any branches starting with b4/
+ heads.remove(mybranch)
+ for head in list(heads):
+ if head.startswith('b4/'):
+ heads.remove(head)
+ if len(heads) > 1:
+ logger.critical('CRITICAL: Multiple branches contain object %s, please pass a branch name as base',
+ enroll_base)
+ logger.critical(' %s', ', '.join(heads))
+ sys.exit(1)
+ if len(heads) < 1:
+ logger.critical('CRITICAL: No other branch contains %s: cannot use as fork base', enroll_base)
+ sys.exit(1)
+ basebranch = heads.pop()
+
+ try:
+ commitcount = get_rev_count(f'{forkpoint}..')
+ except RuntimeError as ex:
+ logger.critical('CRITICAL: could not use %s as fork point:', enroll_base)
+ logger.critical(' %s', ex)
+ sys.exit(1)
+
+ if commitcount:
+ logger.info('Will track %s commits', commitcount)
+ else:
+ logger.info('NOTE: No new commits since fork-point "%s"', enroll_base)
+
+ if commitcount and strategy == 'commit':
+ gitargs = ['rev-parse', 'HEAD']
+ lines = b4.git_get_command_lines(None, gitargs)
+ if not lines:
+ logger.critical('CRITICAL: Could not rev-parse current HEAD')
+ sys.exit(1)
+ endpoint = lines[0].strip()
+ cherry_range = f'{forkpoint}..{endpoint}'
+ # Reset current branch to the forkpoint
+ gitargs = ['reset', '--hard', forkpoint]
+ ecode, out = b4.git_run_command(None, gitargs, logstderr=True)
+ if ecode > 0:
+ logger.critical('CRITICAL: not able to reset current branch to %s', forkpoint)
+ logger.critical(out)
+ sys.exit(1)
+
+ # Try loading existing cover info
+ cover, jdata = load_cover()
+
+ else:
+ logger.critical('CRITICAL: unknown operation requested')
+ sys.exit(1)
+
+ # Store our cover letter strategy in the branch config
+ b4.git_set_config(None, f'branch.{branchname}.b4-prep-cover-strategy', strategy)
+
+ if not cover:
+ # create a default cover letter and store it where the strategy indicates
+ cover = ('EDITME: cover title for %s' % seriesname,
+ '',
+ '# Lines starting with # will be removed from the cover letter. You can use',
+ '# them to add notes or reminders to yourself.',
+ '',
+ 'EDITME: describe the purpose of this series. The information you put here',
+ 'will be used by the project maintainer to make a decision whether your',
+ 'patches should be reviewed, and in what priority order. Please be very',
+ 'detailed and link to any relevant discussions or sites that the maintainer',
+ 'can review to better understand your proposed changes.',
+ '',
+ 'Signed-off-by: %s <%s>' % (usercfg.get('name', ''), usercfg.get('email', '')),
+ '',
+ '# You can add other trailers to the cover letter. Any email addresses found in',
+ '# these trailers will be added to the addresses specified/generated during',
+ '# the b4 send stage.',
+ '',
+ '',
+ )
+ cover = '\n'.join(cover)
+ logger.info('Created the default cover letter, you can edit with --edit-cover.')
+
+ if not tracking:
+ # We don't need all the entropy of uuid, just some of it
+ changeid = '%s-%s-%s' % (datetime.date.today().strftime('%Y%m%d'), slug, uuid.uuid4().hex[:12])
+ if revision is None:
+ revision = 1
+ tracking = {
+ 'series': {
+ 'revision': revision,
+ 'change-id': changeid,
+ 'base-branch': basebranch,
+ },
+ }
+ if thread_msgid:
+ tracking['series']['from-thread'] = thread_msgid
+
+ store_cover(cover, tracking, new=True)
+ if cherry_range:
+ gitargs = ['cherry-pick', cherry_range]
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ # Woops, this is bad! At least tell them where the commit range is.
+ logger.critical('Could not cherry-pick commits from range %s', cherry_range)
+ sys.exit(1)
+
+ if patches:
+ logger.info('Applying %s patches', len(patches))
+ logger.info('---')
+ ifh = io.StringIO()
+ b4.save_git_am_mbox(patches, ifh)
+ ambytes = ifh.getvalue().encode()
+ ecode, out = b4.git_run_command(None, ['am'], stdin=ambytes, logstderr=True)
+ logger.info(out.strip())
+ if ecode > 0:
+ logger.critical('Could not apply patches from thread: %s', out)
+ sys.exit(ecode)
+ logger.info('---')
+ logger.info('NOTE: any follow-up trailers were ignored; apply them with b4 trailers -u')
+
+
+def make_magic_json(data: dict) -> str:
+ mj = (f'{MAGIC_MARKER}\n'
+ '# This section is used internally by b4 prep for tracking purposes.\n')
+ return mj + json.dumps(data, indent=2)
+
+
+def load_cover(strip_comments: bool = False) -> Tuple[str, dict]:
+ strategy = get_cover_strategy()
+ if strategy in {'commit', 'tip-commit'}:
+ cover_commit = find_cover_commit()
+ if not cover_commit:
+ cover = ''
+ tracking = dict()
+ else:
+ gitargs = ['show', '-s', '--format=%B', cover_commit]
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ logger.critical('CRITICAL: unable to load cover letter')
+ sys.exit(1)
+ contents = out
+ # Split on MAGIC_MARKER
+ cover, magic_json = contents.split(MAGIC_MARKER)
+ # drop everything until the first {
+ junk, mdata = magic_json.split('{', maxsplit=1)
+ tracking = json.loads('{' + mdata)
+
+ elif strategy == 'branch-description':
+ mybranch = b4.git_get_current_branch()
+ bcfg = b4.get_config_from_git(rf'branch\.{mybranch}\..*')
+ cover = bcfg.get('description', '')
+ tracking = json.loads(bcfg.get('b4-tracking', '{}'))
+
+ else:
+ logger.critical('Not yet supported for %s cover strategy', strategy)
+ sys.exit(0)
+
+ logger.debug('tracking data: %s', tracking)
+ if strip_comments:
+ cover = re.sub(r'^#.*$', '', cover, flags=re.M)
+ while '\n\n\n' in cover:
+ cover = cover.replace('\n\n\n', '\n\n')
+ return cover.strip(), tracking
+
+
+def store_cover(content: str, tracking: dict, new: bool = False) -> None:
+ strategy = get_cover_strategy()
+ if strategy in {'commit', 'tip-commit'}:
+ cover_message = content + '\n\n' + make_magic_json(tracking)
+ if new:
+ args = ['commit', '--allow-empty', '-F', '-']
+ ecode, out = b4.git_run_command(None, args, stdin=cover_message.encode(), logstderr=True)
+ if ecode > 0:
+ logger.critical('CRITICAL: Generating cover letter commit failed:')
+ logger.critical(out)
+ raise RuntimeError('Error saving cover letter')
+ else:
+ commit = find_cover_commit()
+ if not commit:
+ logger.critical('CRITICAL: Could not find the cover letter commit.')
+ raise RuntimeError('Error saving cover letter (commit not found)')
+ fred = FRCommitMessageEditor()
+ fred.add(commit, cover_message)
+ args = fr.FilteringOptions.parse_args(['--force', '--quiet', '--refs', f'{commit}~1..HEAD'])
+ args.refs = [f'{commit}~1..HEAD']
+ frf = fr.RepoFilter(args, commit_callback=fred.callback)
+ logger.info('Invoking git-filter-repo to update the cover letter.')
+ frf.run()
+
+ if strategy == 'branch-description':
+ mybranch = b4.git_get_current_branch(None)
+ b4.git_set_config(None, f'branch.{mybranch}.description', content)
+ trackstr = json.dumps(tracking)
+ b4.git_set_config(None, f'branch.{mybranch}.b4-tracking', trackstr)
+ logger.info('Updated branch description and tracking info.')
+
+
+# Valid cover letter strategies:
+# 'commit': in an empty commit at the start of the series : implemented
+# 'branch-description': in the branch description : implemented
+# 'tip-commit': in an empty commit at the tip of the branch : implemented
+# 'tag': in an annotated tag at the tip of the branch : TODO
+# 'tip-merge': in an empty merge commit at the tip of the branch : TODO
+# (once/if git upstream properly supports it)
+
+def get_cover_strategy(branch: Optional[str] = None) -> str:
+ if branch is None:
+ branch = b4.git_get_current_branch()
+ # Check local branch config for the strategy
+ bconfig = b4.get_config_from_git(rf'branch\.{branch}\..*')
+ if 'b4-prep-cover-strategy' in bconfig:
+ strategy = bconfig.get('b4-prep-cover-strategy')
+ logger.debug('Got strategy=%s from branch-config', strategy)
+ else:
+ config = b4.get_main_config()
+ strategy = config.get('prep-cover-strategy', 'commit')
+
+ if strategy in {'commit', 'branch-description', 'tip-commit'}:
+ return strategy
+
+ logger.critical('CRITICAL: unknown prep-cover-strategy: %s', strategy)
+ sys.exit(1)
+
+
+def is_prep_branch() -> bool:
+ mybranch = b4.git_get_current_branch()
+ strategy = get_cover_strategy(mybranch)
+ if strategy in {'commit', 'tip-commit'}:
+ if find_cover_commit() is None:
+ return False
+ return True
+ if strategy == 'branch-description':
+ # See if we have b4-tracking set for this branch
+ bcfg = b4.get_config_from_git(rf'branch\.{mybranch}\..*')
+ if bcfg.get('b4-tracking'):
+ return True
+ return False
+
+ logger.critical('CRITICAL: unknown cover strategy: %s', strategy)
+ sys.exit(1)
+
+
+def find_cover_commit() -> Optional[str]:
+ # Walk back commits until we find the cover letter
+ # Our covers always contain the MAGIC_MARKER line
+ logger.debug('Looking for the cover letter commit with magic marker "%s"', MAGIC_MARKER)
+ gitargs = ['log', '--grep', MAGIC_MARKER, '-F', '--pretty=oneline', '--max-count=1', '--since=1.year']
+ lines = b4.git_get_command_lines(None, gitargs)
+ if not lines:
+ return None
+ found = lines[0].split()[0]
+ logger.debug('Cover commit found in %s', found)
+ return found
+
+
+class FRCommitMessageEditor:
+ edit_map: dict
+
+ def __init__(self, edit_map: Optional[dict] = None):
+ if edit_map:
+ self.edit_map = edit_map
+ else:
+ self.edit_map = dict()
+
+ def add(self, commit: str, message: str):
+ self.edit_map[commit.encode()] = message.encode()
+
+ def callback(self, commit, metadata): # noqa
+ if commit.original_id in self.edit_map:
+ commit.message = self.edit_map[commit.original_id]
+
+
+def edit_cover() -> None:
+ cover, tracking = load_cover()
+ # What's our editor? And yes, the default is vi, bite me.
+ corecfg = b4.get_config_from_git(r'core\..*', {'editor': os.environ.get('EDITOR', 'vi')})
+ editor = corecfg.get('editor')
+ logger.debug('editor=%s', editor)
+ # We give it a suffix .rst in hopes that editors autoload restructured-text rules
+ with tempfile.NamedTemporaryFile(suffix='.rst') as temp_cover:
+ temp_cover.write(cover.encode())
+ temp_cover.seek(0)
+ sp = shlex.shlex(editor, posix=True)
+ sp.whitespace_split = True
+ cmdargs = list(sp) + [temp_cover.name]
+ logger.debug('Running %s' % ' '.join(cmdargs))
+ sp = subprocess.Popen(cmdargs)
+ sp.wait()
+ new_cover = temp_cover.read().decode(errors='replace').strip()
+
+ if new_cover == cover:
+ logger.info('Cover letter unchanged.')
+ return
+ if not len(new_cover.strip()):
+ logger.info('New cover letter blank, leaving current one unchanged.')
+ return
+
+ store_cover(new_cover, tracking)
+ logger.info('Cover letter updated.')
+
+
+def get_series_start() -> str:
+ strategy = get_cover_strategy()
+ forkpoint = None
+ if strategy == 'commit':
+ # Easy, we start at the cover letter commit
+ return find_cover_commit()
+ if strategy == 'branch-description':
+ mybranch = b4.git_get_current_branch()
+ bcfg = b4.get_config_from_git(rf'branch\.{mybranch}\..*')
+ tracking = bcfg.get('b4-tracking')
+ if not tracking:
+ logger.critical('CRITICAL: Could not find tracking info for %s', mybranch)
+ sys.exit(1)
+ jdata = json.loads(tracking)
+ basebranch = jdata['series']['base-branch']
+ try:
+ forkpoint = get_base_forkpoint(basebranch)
+ commitcount = get_rev_count(f'{forkpoint}..')
+ except RuntimeError:
+ sys.exit(1)
+ logger.debug('series_start: %s, commitcount=%s', forkpoint, commitcount)
+ if strategy == 'tip-commit':
+ cover, tracking = load_cover()
+ basebranch = tracking['series']['base-branch']
+ try:
+ forkpoint = get_base_forkpoint(basebranch)
+ commitcount = get_rev_count(f'{forkpoint}..HEAD~1')
+ except RuntimeError:
+ sys.exit(1)
+ logger.debug('series_start: %s, commitcount=%s', forkpoint, commitcount)
+
+ return forkpoint
+
+
+def update_trailers(cmdargs: argparse.Namespace) -> None:
+ usercfg = b4.get_user_config()
+ if 'name' not in usercfg or 'email' not in usercfg:
+ logger.critical('CRITICAL: Please set your user.name and user.email')
+ sys.exit(1)
+
+ ignore_commits = None
+ # If we are in an b4-prep branch, we start from the beginning of the series
+ if is_prep_branch():
+ start = get_series_start()
+ end = 'HEAD'
+ cover, tracking = load_cover(strip_comments=True)
+ changeid = tracking['series'].get('change-id')
+ msgid = tracking['series'].get('from-thread')
+ strategy = get_cover_strategy()
+ if strategy in {'commit', 'tip-commit'}:
+ # We need to me sure we ignore the cover commit
+ cover_commit = find_cover_commit()
+ if cover_commit:
+ ignore_commits = {cover_commit}
+
+ elif cmdargs.msgid:
+ msgid = b4.get_msgid(cmdargs)
+ changeid = None
+ myemail = usercfg['email']
+ # There doesn't appear to be a great way to find the first commit
+ # where we're NOT the committer, so we get all commits since range specified where
+ # we're the committer and stop at the first non-contiguous parent
+ gitargs = ['log', '-F', '--no-merges', f'--committer={myemail}', '--since', cmdargs.since, '--format=%H %P']
+ lines = b4.git_get_command_lines(None, gitargs)
+ if not lines:
+ logger.critical('CRITICAL: could not find any commits where committer=%s', myemail)
+ sys.exit(1)
+
+ prevparent = prevcommit = end = None
+ for line in lines:
+ commit, parent = line.split()
+ if end is None:
+ end = commit
+ if prevparent is None:
+ prevparent = parent
+ continue
+ if prevcommit is None:
+ prevcommit = commit
+ if prevparent != commit:
+ break
+ prevparent = parent
+ prevcommit = commit
+ start = f'{prevcommit}~1'
+ else:
+ logger.critical('CRITICAL: Please specify -F msgid to look up trailers from remote.')
+ sys.exit(1)
+
+ try:
+ patches = b4.git_range_to_patches(None, start, end, ignore_commits=ignore_commits)
+ except RuntimeError as ex:
+ logger.critical('CRITICAL: Failed to convert range to patches: %s', ex)
+ sys.exit(1)
+
+ logger.info('Calculating patch-ids from %s commits', len(patches)-1)
+ commit_map = dict()
+ by_patchid = dict()
+ by_subject = dict()
+ updates = dict()
+ for commit, msg in patches:
+ if not msg:
+ continue
+ commit_map[commit] = msg
+ body = msg.get_payload()
+ patchid = b4.LoreMessage.get_patch_id(body)
+ subject = msg.get('subject')
+ by_subject[subject] = commit
+ by_patchid[patchid] = commit
+
+ list_msgs = list()
+ if changeid:
+ logger.info('Checking change-id "%s"', changeid)
+ query = f'"change-id: {changeid}"'
+ smsgs = b4.get_pi_search_results(query, nocache=True)
+ if smsgs is not None:
+ list_msgs += smsgs
+ if msgid:
+ logger.info('Retrieving thread matching %s', msgid)
+ tmsgs = b4.get_pi_thread_by_msgid(msgid, nocache=True)
+ if tmsgs is not None:
+ list_msgs += tmsgs
+
+ if list_msgs:
+ bbox = b4.LoreMailbox()
+ for list_msg in list_msgs:
+ bbox.add_message(list_msg)
+
+ lser = bbox.get_series(sloppytrailers=cmdargs.sloppytrailers)
+ mismatches = list(lser.trailer_mismatches)
+ for lmsg in lser.patches[1:]:
+ addtrailers = list(lmsg.followup_trailers)
+ if lser.has_cover and len(lser.patches[0].followup_trailers):
+ addtrailers += list(lser.patches[0].followup_trailers)
+ if not addtrailers:
+ logger.debug('No follow-up trailers received to: %s', lmsg.subject)
+ continue
+ commit = None
+ if lmsg.subject in by_subject:
+ commit = by_subject[lmsg.subject]
+ else:
+ patchid = b4.LoreMessage.get_patch_id(lmsg.body)
+ if patchid in by_patchid:
+ commit = by_patchid[patchid]
+ if not commit:
+ logger.debug('No match for %s', lmsg.full_subject)
+ continue
+
+ parts = b4.LoreMessage.get_body_parts(commit_map[commit].get_payload())
+ for fltr in addtrailers:
+ if fltr not in parts[2]:
+ if commit not in updates:
+ updates[commit] = list()
+ updates[commit].append(fltr)
+ # Check if we've applied mismatched trailers already
+ if not cmdargs.sloppytrailers and mismatches:
+ for mismatch in list(mismatches):
+ if b4.LoreTrailer(name=mismatch[0], value=mismatch[1]) in parts[2]:
+ logger.debug('Removing already-applied mismatch %s', mismatch[0])
+ mismatches.remove(mismatch)
+
+ if len(mismatches):
+ logger.critical('---')
+ logger.critical('NOTE: some trailers ignored due to from/email mismatches:')
+ for tname, tvalue, fname, femail in lser.trailer_mismatches:
+ logger.critical(' ! Trailer: %s: %s', tname, tvalue)
+ logger.critical(' Msg From: %s <%s>', fname, femail)
+ logger.critical('NOTE: Rerun with -S to apply them anyway')
+
+ if not updates:
+ logger.info('No trailer updates found.')
+ return
+
+ logger.info('---')
+ # Create the map of new messages
+ fred = FRCommitMessageEditor()
+ for commit, newtrailers in updates.items():
+ # Make it a LoreMessage, so we can run attestation on received trailers
+ cmsg = b4.LoreMessage(commit_map[commit])
+ logger.info(' %s', cmsg.subject)
+ if len(newtrailers):
+ cmsg.followup_trailers = newtrailers
+ cmsg.fix_trailers()
+ fred.add(commit, cmsg.message)
+ logger.info('---')
+ args = fr.FilteringOptions.parse_args(['--force', '--quiet', '--refs', f'{start}..'])
+ args.refs = [f'{start}..']
+ frf = fr.RepoFilter(args, commit_callback=fred.callback)
+ logger.info('Invoking git-filter-repo to update trailers.')
+ frf.run()
+ logger.info('Trailers updated.')
+
+
+def get_addresses_from_cmd(cmdargs: List[str], msgbytes: bytes) -> List[Tuple[str, str]]:
+ # Run this command from git toplevel
+ topdir = b4.git_get_toplevel()
+ ecode, out, err = b4._run_command(cmdargs, stdin=msgbytes, rundir=topdir) # noqa
+ if ecode > 0:
+ logger.critical('CRITICAL: Running %s failed:', ' '.join(cmdargs))
+ logger.critical(err.decode())
+ raise RuntimeError('Running command failed: %s' % ' '.join(cmdargs))
+ addrs = out.strip().decode()
+ if not addrs:
+ return list()
+ return utils.getaddresses(addrs.split('\n'))
+
+
+def get_series_details(start_commit: str) -> Tuple[str, str, str]:
+ # Not sure if we can reasonably expect all automation to handle this correctly
+ # gitargs = ['describe', '--long', f'{cover_commit}~1']
+ gitargs = ['rev-parse', f'{start_commit}~1']
+ lines = b4.git_get_command_lines(None, gitargs)
+ base_commit = lines[0]
+ strategy = get_cover_strategy()
+ if strategy == 'tip-commit':
+ cover_commit = find_cover_commit()
+ endrange = f'{cover_commit}~1'
+ else:
+ endrange = ''
+ gitargs = ['shortlog', f'{start_commit}..{endrange}']
+ ecode, shortlog = b4.git_run_command(None, gitargs)
+ gitargs = ['diff', '--stat', f'{start_commit}..{endrange}']
+ ecode, diffstat = b4.git_run_command(None, gitargs)
+ return base_commit, shortlog.rstrip(), diffstat.rstrip()
+
+
+def print_pretty_addrs(addrs: list, hdrname: str) -> None:
+ if len(addrs) < 1:
+ return
+ logger.info('%s: %s', hdrname, b4.format_addrs([addrs[0]]))
+ if len(addrs) > 1:
+ for addr in addrs[1:]:
+ logger.info(' %s', b4.format_addrs([addr]))
+
+
+def get_sent_tag_as_patches(tagname: str, revision: Optional[str] = None,
+ prefixes: Optional[List[str]] = None) -> List[Tuple[str, email.message.Message]]:
+ gitargs = ['cat-file', '-p', tagname]
+ ecode, tagmsg = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ raise RuntimeError('No such tag: %s' % tagname)
+ # junk the headers
+ junk, cover = tagmsg.split('\n\n', maxsplit=1)
+ # Check that we have base-commit: in the body
+ matches = re.search(r'^base-commit:\s*(.*)$', cover, flags=re.I | re.M)
+ if not matches:
+ raise RuntimeError('Tag %s does not contain base-commit info' % tagname)
+ base_commit = matches.groups()[0]
+ matches = re.search(r'^change-id:\s*(.*)$', cover, flags=re.I | re.M)
+ if not matches:
+ raise RuntimeError('Tag %s does not contain change-id info' % tagname)
+ change_id = matches.groups()[0]
+ if revision is None:
+ matches = re.search(r'.*-v(\d+)$', tagname, flags=re.I | re.M)
+ if not matches:
+ raise RuntimeError('Could not grok revision number from %s' % tagname)
+ revision = matches.groups()[0]
+
+ # First line is the subject
+ csubject, cbody = cover.split('\n', maxsplit=1)
+ cbody = cbody.strip() + '\n-- \n' + b4.get_email_signature()
+
+ cmsg = email.message.EmailMessage()
+ cmsg.add_header('Subject', csubject)
+ cmsg.set_payload(cbody.lstrip(), charset='utf-8')
+ if prefixes is None:
+ prefixes = list()
+ prefixes.append(f'v{revision}')
+ seriests = int(time.time())
+ msgid_tpt = make_msgid_tpt(change_id, revision)
+ usercfg = b4.get_user_config()
+ mailfrom = (usercfg.get('name'), usercfg.get('email'))
+
+ patches = b4.git_range_to_patches(None, base_commit, tagname,
+ covermsg=cmsg, prefixes=prefixes,
+ msgid_tpt=msgid_tpt,
+ seriests=seriests,
+ thread=True,
+ mailfrom=mailfrom)
+ return patches
+
+
+def make_msgid_tpt(change_id: str, revision: str, domain: Optional[str] = None) -> str:
+ if not domain:
+ usercfg = b4.get_user_config()
+ myemail = usercfg.get('email')
+ if myemail:
+ domain = re.sub(r'^[^@]*@', '', myemail)
+ else:
+ # Use the hostname of the system
+ import platform
+ domain = platform.node()
+
+ chunks = change_id.rsplit('-', maxsplit=1)
+ stablepart = chunks[0]
+ # Message-IDs must not be predictable to avoid stuffing attacks
+ randompart = uuid.uuid4().hex[:12]
+ msgid_tpt = f'<{stablepart}-v{revision}-%s-{randompart}@{domain}>'
+ return msgid_tpt
+
+
+def get_prep_branch_as_patches(prefixes: Optional[List[str]] = None,
+ movefrom: bool = True,
+ thread: bool = True) -> List[Tuple[str, email.message.Message]]:
+ cover, tracking = load_cover(strip_comments=True)
+ config = b4.get_main_config()
+ cover_template = DEFAULT_COVER_TEMPLATE
+ if config.get('prep-cover-template'):
+ # Try to load this template instead
+ try:
+ cover_template = b4.read_template(config['prep-cover-template'])
+ except FileNotFoundError:
+ logger.critical('ERROR: prep-cover-template says to use %s, but it does not exist',
+ config['prep-cover-template'])
+ sys.exit(2)
+
+ # Put together the cover letter
+ csubject, cbody = cover.split('\n', maxsplit=1)
+ start_commit = get_series_start()
+ base_commit, shortlog, diffstat = get_series_details(start_commit=start_commit)
+ change_id = tracking['series'].get('change-id')
+ revision = tracking['series'].get('revision')
+ tptvals = {
+ 'subject': csubject,
+ 'cover': cbody.strip(),
+ 'shortlog': shortlog,
+ 'diffstat': diffstat,
+ 'change_id': change_id,
+ 'base_commit': base_commit,
+ 'signature': b4.get_email_signature(),
+ }
+ body = Template(cover_template.lstrip()).safe_substitute(tptvals)
+ cmsg = email.message.EmailMessage()
+ cmsg.add_header('Subject', csubject)
+ cmsg.set_payload(body, charset='utf-8')
+ # Store tracking info in the header in a safe format, which should allow us to
+ # fully restore our work from the already sent series.
+ ztracking = gzip.compress(bytes(json.dumps(tracking), 'utf-8'))
+ b64tracking = base64.b64encode(ztracking)
+ cmsg.add_header('X-b4-tracking', ' '.join(textwrap.wrap(b64tracking.decode(), width=78)))
+ if prefixes is None:
+ prefixes = list()
+
+ prefixes.append(f'v{revision}')
+ seriests = int(time.time())
+ msgid_tpt = make_msgid_tpt(change_id, revision)
+ if movefrom:
+ usercfg = b4.get_user_config()
+ mailfrom = (usercfg.get('name'), usercfg.get('email'))
+ else:
+ mailfrom = None
+
+ strategy = get_cover_strategy()
+ ignore_commits = None
+ if strategy in {'commit', 'tip-commit'}:
+ cover_commit = find_cover_commit()
+ if cover_commit:
+ ignore_commits = {cover_commit}
+
+ patches = b4.git_range_to_patches(None, start_commit, 'HEAD',
+ covermsg=cmsg, prefixes=prefixes,
+ msgid_tpt=msgid_tpt,
+ seriests=seriests,
+ thread=thread,
+ mailfrom=mailfrom,
+ ignore_commits=ignore_commits)
+ return patches
+
+
+def format_patch(output_dir: str) -> None:
+ try:
+ patches = get_prep_branch_as_patches(thread=False, movefrom=False)
+ except RuntimeError as ex:
+ logger.critical('CRITICAL: Failed to convert range to patches: %s', ex)
+ sys.exit(1)
+
+ logger.info('Writing %s messages into %s', len(patches), output_dir)
+ pathlib.Path(output_dir).mkdir(parents=True, exist_ok=True)
+ for commit, msg in patches:
+ if not msg:
+ continue
+ msg.policy = email.policy.EmailPolicy(utf8=True, cte_type='8bit')
+ subject = msg.get('Subject', '')
+ ls = b4.LoreSubject(subject)
+ filen = '%s.patch' % ls.get_slug(sep='-')
+ with open(os.path.join(output_dir, filen), 'w') as fh:
+ fh.write(msg.as_string(unixfrom=True, maxheaderlen=0))
+ logger.info(' %s', filen)
+
+
+def cmd_send(cmdargs: argparse.Namespace) -> None:
+ if cmdargs.auth_new:
+ auth_new()
+ return
+ if cmdargs.auth_verify:
+ auth_verify(cmdargs)
+ return
+
+ # Should we make the sent/ prefix configurable?
+ tagprefix = 'sent/'
+ mybranch = b4.git_get_current_branch()
+ prefixes = cmdargs.prefixes
+ if cmdargs.prefixes is None:
+ prefixes = list()
+
+ if cmdargs.resend:
+ # We accept both a full tag name and just a vN short form
+ matches = re.search(r'^v(\d+)$', cmdargs.resend)
+ if matches:
+ revision = matches.groups()[0]
+ if mybranch.startswith('b4/'):
+ tagname = f'{tagprefix}{mybranch[3:]}-v{revision}'
+ else:
+ tagname = f'{tagprefix}{mybranch}-v{revision}'
+ else:
+ revision = None
+ tagname = cmdargs.resend
+
+ prefixes.append('RESEND')
+ try:
+ patches = get_sent_tag_as_patches(tagname, revision=revision, prefixes=prefixes)
+ except RuntimeError as ex:
+ logger.critical('CRITICAL: Failed to convert tag to patches: %s', ex)
+ sys.exit(1)
+
+ else:
+ # Check if the cover letter has 'EDITME' in it
+ cover, tracking = load_cover(strip_comments=True)
+ if 'EDITME' in cover:
+ logger.critical('CRITICAL: Looks like the cover letter needs to be edited first.')
+ logger.info('---')
+ logger.info(cover)
+ logger.info('---')
+ sys.exit(1)
+
+ try:
+ patches = get_prep_branch_as_patches(prefixes=prefixes)
+ except RuntimeError as ex:
+ logger.critical('CRITICAL: Failed to convert range to patches: %s', ex)
+ sys.exit(1)
+ logger.info('Converted the branch to %s patches', len(patches)-1)
+
+ config = b4.get_main_config()
+ usercfg = b4.get_user_config()
+ myemail = usercfg.get('email')
+
+ seen = set()
+ todests = list()
+ trailers = set()
+ if config.get('send-series-to'):
+ for pair in utils.getaddresses([config.get('send-series-to')]):
+ if pair[1] not in seen:
+ seen.add(pair[1])
+ todests.append(pair)
+ ccdests = list()
+ if config.get('send-series-cc'):
+ for pair in utils.getaddresses([config.get('send-series-cc')]):
+ if pair[1] not in seen:
+ seen.add(pair[1])
+ ccdests.append(pair)
+ excludes = set()
+ # These override config values
+ if cmdargs.to:
+ todests = [('', x) for x in cmdargs.to]
+ seen.update(set(cmdargs.to))
+ if cmdargs.cc:
+ ccdests = [('', x) for x in cmdargs.cc]
+ seen.update(set(cmdargs.cc))
+
+ if not cmdargs.no_auto_to_cc:
+ logger.info('Populating the To: and Cc: fields with automatically collected addresses')
+
+ topdir = b4.git_get_toplevel()
+ # Use sane tocmd and cccmd defaults if we find a get_maintainer.pl
+ tocmdstr = tocmd = None
+ cccmdstr = cccmd = None
+ getm = os.path.join(topdir, 'scripts', 'get_maintainer.pl')
+ if config.get('send-auto-to-cmd'):
+ tocmdstr = config.get('send-auto-to-cmd')
+ elif os.access(getm, os.X_OK):
+ logger.info('Invoking get_maintainer.pl for To: addresses')
+ tocmdstr = f'{getm} --nogit --nogit-fallback --nogit-chief-penguins --norolestats --nol'
+ if config.get('send-auto-cc-cmd'):
+ cccmdstr = config.get('send-auto-cc-cmd')
+ elif os.access(getm, os.X_OK):
+ logger.info('Invoking get_maintainer.pl for Cc: addresses')
+ cccmdstr = f'{getm} --nogit --nogit-fallback --nogit-chief-penguins --norolestats --nom'
+
+ if tocmdstr:
+ sp = shlex.shlex(tocmdstr, posix=True)
+ sp.whitespace_split = True
+ tocmd = list(sp)
+ if cccmdstr:
+ sp = shlex.shlex(cccmdstr, posix=True)
+ sp.whitespace_split = True
+ cccmd = list(sp)
+
+ seen = set()
+ # Go through them again to make to/cc headers
+ for commit, msg in patches:
+ if not msg:
+ continue
+ body = msg.get_payload()
+ parts = b4.LoreMessage.get_body_parts(body)
+ trailers.update(parts[2])
+ msgbytes = msg.as_bytes()
+ if commit and tocmd:
+ for pair in get_addresses_from_cmd(tocmd, msgbytes):
+ if pair[1] not in seen:
+ seen.add(pair[1])
+ todests.append(pair)
+ if commit and cccmd:
+ for pair in get_addresses_from_cmd(cccmd, msgbytes):
+ if pair[1] not in seen:
+ seen.add(pair[1])
+ ccdests.append(pair)
+
+ # add addresses seen in trailers
+ for ltr in trailers:
+ if ltr.addr and ltr.addr[1] not in seen:
+ seen.add(ltr.addr[1])
+ ccdests.append(ltr.addr)
+
+ excludes = b4.get_excluded_addrs()
+ if cmdargs.not_me_too:
+ excludes.add(myemail)
+
+ allto = list()
+ allcc = list()
+ alldests = set()
+
+ if todests:
+ allto = b4.cleanup_email_addrs(todests, excludes, None)
+ alldests.update(set([x[1] for x in allto]))
+ if ccdests:
+ allcc = b4.cleanup_email_addrs(ccdests, excludes, None)
+ alldests.update(set([x[1] for x in allcc]))
+
+ if not len(allto):
+ # Move all cc's into the To field if there's nothing in "To"
+ allto = list(allcc)
+ allcc = list()
+
+ if cmdargs.output_dir:
+ cmdargs.dryrun = True
+ logger.info('Will write out messages into %s', cmdargs.output_dir)
+ pathlib.Path(cmdargs.output_dir).mkdir(parents=True, exist_ok=True)
+
+ # Give the user the last opportunity to bail out
+ if not cmdargs.dryrun:
+ logger.info('Will send the following messages:')
+ logger.info('---')
+ print_pretty_addrs(allto, 'To')
+ print_pretty_addrs(allcc, 'Cc')
+ logger.info('---')
+ for commit, msg in patches:
+ if not msg:
+ continue
+ logger.info(' %s', re.sub(r'\s+', ' ', msg.get('Subject')))
+ logger.info('---')
+ try:
+ input('Press Enter to send or Ctrl-C to abort')
+ except KeyboardInterrupt:
+ logger.info('')
+ sys.exit(130)
+
+ # And now we go through each message to set addressees and send them off
+ sign = True
+ if cmdargs.no_sign or config.get('send-no-patatt-sign', '').lower() in {'yes', 'true', 'y'}:
+ sign = False
+
+ cover_msgid = cover_body = None
+ # TODO: Need to send obsoleted-by follow-ups, just need to figure out where.
+ send_msgs = list()
+ for commit, msg in patches:
+ if not msg:
+ continue
+ if cover_msgid is None:
+ cover_msgid = b4.LoreMessage.get_clean_msgid(msg)
+ lsubject = b4.LoreSubject(msg.get('subject'))
+ cbody = msg.get_payload()
+ # Remove signature
+ chunks = cbody.rsplit('\n-- \n')
+ if len(chunks) > 1:
+ cbody = chunks[0] + '\n'
+ cover_body = lsubject.subject + '\n\n' + cbody
+
+ msg.add_header('To', b4.format_addrs(allto))
+ if allcc:
+ msg.add_header('Cc', b4.format_addrs(allcc))
+
+ send_msgs.append(msg)
+
+ if config.get('send-endpoint-web'):
+ # Web endpoint always requires signing
+ if not sign:
+ logger.critical('CRITICAL: Web endpoint is defined for sending, but signing is turned off')
+ logger.critical(' Please re-enable signing or use SMTP')
+ sys.exit(1)
+
+ sent = b4.send_mail(None, send_msgs, fromaddr=None, destaddrs=None, patatt_sign=True,
+ dryrun=cmdargs.dryrun, output_dir=cmdargs.output_dir, use_web_endpoint=True)
+ else:
+ identity = config.get('sendemail-identity')
+ try:
+ smtp, fromaddr = b4.get_smtp(identity, dryrun=cmdargs.dryrun)
+ except Exception as ex: # noqa
+ logger.critical('Failed to configure the smtp connection:')
+ logger.critical(ex)
+ sys.exit(1)
+
+ sent = b4.send_mail(smtp, send_msgs, fromaddr=fromaddr, destaddrs=alldests, patatt_sign=sign,
+ dryrun=cmdargs.dryrun, output_dir=cmdargs.output_dir, use_web_endpoint=False)
+
+ logger.info('---')
+ if cmdargs.dryrun:
+ logger.info('DRYRUN: Would have sent %s messages', len(send_msgs))
+ return
+ if not sent:
+ logger.critical('CRITICAL: Was not able to send messages.')
+ sys.exit(1)
+
+ logger.info('Sent %s messages', sent)
+
+ if cmdargs.resend:
+ logger.debug('Not updating cover/tracking on resend')
+ return
+
+ cover, tracking = load_cover(strip_comments=True)
+ revision = tracking['series']['revision']
+ if mybranch.startswith('b4/'):
+ tagname = f'{tagprefix}{mybranch[3:]}-v{revision}'
+ else:
+ tagname = f'{tagprefix}{mybranch}-v{revision}'
+
+ logger.debug('checking if we already have %s', tagname)
+ gitargs = ['rev-parse', f'refs/tags/{tagname}']
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ try:
+ strategy = get_cover_strategy()
+ if strategy == 'commit':
+ # Detach the head at our parent commit and apply the cover-less series
+ cover_commit = find_cover_commit()
+ gitargs = ['checkout', f'{cover_commit}~1']
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ raise RuntimeError('Could not switch to a detached head')
+ # cherry-pick from cover letter to the last commit
+ last_commit = patches[-1][0]
+ gitargs = ['cherry-pick', f'{cover_commit}..{last_commit}']
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ raise RuntimeError('Could not cherry-pick the cover-less range')
+ # Find out the head commit
+ gitargs = ['rev-parse', 'HEAD']
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ raise RuntimeError('Could not find the HEAD commit of the detached head')
+ tagcommit = out.strip()
+ # Switch back to our branch
+ gitargs = ['checkout', mybranch]
+ ecode, out = b4.git_run_command(None, gitargs)
+ if ecode > 0:
+ raise RuntimeError('Could not switch back to %s' % mybranch)
+ elif strategy == 'tip-commit':
+ cover_commit = find_cover_commit()
+ tagcommit = f'{cover_commit}~1'
+ else:
+ tagcommit = 'HEAD'
+
+ logger.info('Tagging %s', tagname)
+ gitargs = ['tag', '-a', '-F', '-', tagname, tagcommit]
+ ecode, out = b4.git_run_command(None, gitargs, stdin=cover_body.encode())
+ if ecode > 0:
+ # Not a fatal error, just complain about it
+ logger.info('Could not tag %s as %s:', tagcommit, tagname)
+ logger.info(out)
+
+ except RuntimeError as ex:
+ logger.critical('Error tagging the revision: %s', ex)
+
+ else:
+ logger.info('NOTE: Tagname %s already exists', tagname)
+
+ if not cover_msgid:
+ return
+
+ logger.info('Recording series message-id in cover letter tracking')
+ cover, tracking = load_cover(strip_comments=False)
+ vrev = f'v{revision}'
+ if 'history' not in tracking['series']:
+ tracking['series']['history'] = dict()
+ if vrev not in tracking['series']['history']:
+ tracking['series']['history'][vrev] = list()
+ tracking['series']['history'][vrev].append(cover_msgid)
+
+ oldrev = tracking['series']['revision']
+ newrev = oldrev + 1
+ tracking['series']['revision'] = newrev
+ sections = cover.split('---\n')
+ vrev = f'v{oldrev}'
+ if 'history' in tracking['series'] and vrev in tracking['series']['history']:
+ # Use the latest link we have
+ config = b4.get_main_config()
+ oldrev_link = config.get('linkmask') % tracking['series']['history'][vrev][-1]
+ else:
+ oldrev_link = 'EDITME (not found in tracking)'
+ tptvals = {
+ 'oldrev': oldrev,
+ 'newrev': newrev,
+ 'oldrev_link': oldrev_link,
+ }
+ prepend = Template(DEFAULT_CHANGELOG_TEMPLATE.lstrip()).safe_substitute(tptvals)
+ found = False
+ new_sections = list()
+ for section in sections:
+ if re.search(r'^changes in v\d+', section, flags=re.I | re.M):
+ # This is our section
+ new_sections.append(prepend + section)
+ found = True
+ else:
+ new_sections.append(section)
+ if found:
+ new_cover = '---\n'.join(new_sections)
+ else:
+ new_cover = cover + '\n\n---\n' + prepend
+
+ logger.info('Created new revision v%s', newrev)
+ logger.info('Updating cover letter with templated changelog entries.')
+ store_cover(new_cover, tracking)
+
+
+def check_can_gfr() -> None:
+ if not can_gfr:
+ logger.critical('ERROR: b4 submit requires git-filter-repo. You should be able')
+ logger.critical(' to install it from your distro packages, or from pip.')
+ sys.exit(1)
+
+
+def show_revision() -> None:
+ cover, tracking = load_cover()
+ ts = tracking['series']
+ logger.info('v%s', ts.get('revision'))
+ if 'history' in ts:
+ config = b4.get_main_config()
+ logger.info('---')
+ for rn, links in ts['history'].items():
+ for link in links:
+ logger.info(' %s: %s', rn, config['linkmask'] % link)
+
+
+def force_revision(forceto: int) -> None:
+ cover, tracking = load_cover()
+ tracking['series']['revision'] = forceto
+ logger.info('Forced revision to v%s', forceto)
+ store_cover(cover, tracking)
+
+
+def cmd_prep(cmdargs: argparse.Namespace) -> None:
+ check_can_gfr()
+ status = b4.git_get_repo_status()
+ if len(status):
+ logger.critical('CRITICAL: Repository contains uncommitted changes.')
+ logger.critical(' Stash or commit them first.')
+ sys.exit(1)
+
+ if cmdargs.edit_cover:
+ return edit_cover()
+
+ if cmdargs.show_revision:
+ return show_revision()
+
+ if cmdargs.force_revision:
+ return force_revision(cmdargs.force_revision)
+
+ if cmdargs.format_patch:
+ return format_patch(cmdargs.format_patch)
+
+ if is_prep_branch():
+ logger.critical('CRITICAL: This appears to already be a b4-prep managed branch.')
+ sys.exit(1)
+
+ return start_new_series(cmdargs)
+
+
+def cmd_trailers(cmdargs: argparse.Namespace) -> None:
+ check_can_gfr()
+ status = b4.git_get_repo_status()
+ if len(status):
+ logger.critical('CRITICAL: Repository contains uncommitted changes.')
+ logger.critical(' Stash or commit them first.')
+ sys.exit(1)
+
+ if cmdargs.update:
+ update_trailers(cmdargs)
diff --git a/b4/mbox.py b/b4/mbox.py
index a7e9f78..c2e1555 100644
--- a/b4/mbox.py
+++ b/b4/mbox.py
@@ -18,6 +18,9 @@ import fnmatch
import shutil
import pathlib
import tempfile
+import io
+import shlex
+import argparse
import urllib.parse
import xml.etree.ElementTree
@@ -25,9 +28,19 @@ import xml.etree.ElementTree
import b4
from typing import Optional, Tuple
+from string import Template
logger = b4.logger
+DEFAULT_MERGE_TEMPLATE = """Merge ${patch_or_series} "${seriestitle}"
+
+${authorname} <${authoremail}> says:
+
+${covermessage}
+
+Link: ${midurl}
+"""
+
def make_am(msgs, cmdargs, msgid):
config = b4.get_main_config()
@@ -35,7 +48,6 @@ def make_am(msgs, cmdargs, msgid):
if outdir == '-':
cmdargs.nocover = True
wantver = cmdargs.wantver
- wantname = cmdargs.wantname
covertrailers = cmdargs.covertrailers
count = len(msgs)
logger.info('Analyzing %s messages in the thread', count)
@@ -90,48 +102,12 @@ def make_am(msgs, cmdargs, msgid):
try:
am_msgs = lser.get_am_ready(noaddtrailers=cmdargs.noaddtrailers,
- covertrailers=covertrailers, trailer_order=config['trailer-order'],
- addmysob=cmdargs.addmysob, addlink=cmdargs.addlink,
- linkmask=config['linkmask'], cherrypick=cherrypick,
- copyccs=cmdargs.copyccs)
+ covertrailers=covertrailers, addmysob=cmdargs.addmysob,
+ addlink=cmdargs.addlink, linkmask=config['linkmask'], cherrypick=cherrypick,
+ copyccs=cmdargs.copyccs, allowbadchars=cmdargs.allowbadchars)
except KeyError:
sys.exit(1)
- if cmdargs.maildir or config.get('save-maildirs', 'no') == 'yes':
- save_maildir = True
- dftext = 'maildir'
- else:
- save_maildir = False
- dftext = 'mbx'
-
- if wantname:
- slug = wantname
- if wantname.find('.') > -1:
- slug = '.'.join(wantname.split('.')[:-1])
- gitbranch = slug
- else:
- slug = lser.get_slug(extended=True)
- gitbranch = lser.get_slug(extended=False)
-
- if outdir != '-':
- am_filename = os.path.join(outdir, f'{slug}.{dftext}')
- am_cover = os.path.join(outdir, f'{slug}.cover')
-
- if os.path.exists(am_filename):
- if os.path.isdir(am_filename):
- shutil.rmtree(am_filename)
- else:
- os.unlink(am_filename)
- if save_maildir:
- b4.save_maildir(am_msgs, am_filename)
- else:
- with open(am_filename, 'w') as fh:
- b4.save_git_am_mbox(am_msgs, fh)
- else:
- am_filename = None
- am_cover = None
- b4.save_git_am_mbox(am_msgs, sys.stdout)
-
logger.info('---')
if cherrypick is None:
@@ -145,9 +121,9 @@ def make_am(msgs, cmdargs, msgid):
# Only check cover letter or first patch
if not lmsg or lmsg.counter > 1:
continue
- for trailer in list(lmsg.followup_trailers):
- if trailer[0].lower() == 'obsoleted-by':
- lmsg.followup_trailers.remove(trailer)
+ for ltr in list(lmsg.followup_trailers):
+ if ltr.lname == 'obsoleted-by':
+ lmsg.followup_trailers.remove(ltr)
if warned:
continue
logger.critical('---')
@@ -160,10 +136,10 @@ def make_am(msgs, cmdargs, msgid):
logger.critical('---')
logger.critical('NOTE: Some trailers were sent to the cover letter:')
tseen = set()
- for trailer in lser.patches[0].followup_trailers:
- if tuple(trailer[:2]) not in tseen:
- logger.critical(' %s: %s', trailer[0], trailer[1])
- tseen.add(tuple(trailer[:2]))
+ for ltr in lser.patches[0].followup_trailers:
+ if ltr not in tseen:
+ logger.critical(' %s', ltr.as_string(omit_extinfo=True))
+ tseen.add(ltr)
logger.critical('NOTE: Rerun with -t to apply them to all patches')
if len(lser.trailer_mismatches):
logger.critical('---')
@@ -173,6 +149,17 @@ def make_am(msgs, cmdargs, msgid):
logger.critical(' Msg From: %s <%s>', fname, femail)
logger.critical('NOTE: Rerun with -S to apply them anyway')
+ top_msgid = None
+ first_body = None
+ for lmsg in lser.patches:
+ if lmsg is not None:
+ first_body = lmsg.body
+ top_msgid = lmsg.msgid
+ break
+ if top_msgid is None:
+ logger.critical('Could not find any patches in the series.')
+ return
+
topdir = b4.git_get_toplevel()
if cmdargs.threeway:
@@ -194,62 +181,225 @@ def make_am(msgs, cmdargs, msgid):
if not lser.complete and not cmdargs.cherrypick:
logger.critical('WARNING: Thread incomplete!')
- if lser.has_cover and not cmdargs.nocover:
- lser.save_cover(am_cover)
+ gitbranch = lser.get_slug(extended=False)
+ am_filename = None
- top_msgid = None
- first_body = None
- for lmsg in lser.patches:
- if lmsg is not None:
- first_body = lmsg.body
- top_msgid = lmsg.msgid
- break
- if top_msgid is None:
- logger.critical('Could not find any patches in the series.')
- return
+ if cmdargs.subcmd == 'am':
+ wantname = cmdargs.wantname
+ if cmdargs.maildir or config.get('save-maildirs', 'no') == 'yes':
+ save_maildir = True
+ dftext = 'maildir'
+ else:
+ save_maildir = False
+ dftext = 'mbx'
+
+ if wantname:
+ slug = wantname
+ if wantname.find('.') > -1:
+ slug = '.'.join(wantname.split('.')[:-1])
+ gitbranch = slug
+ else:
+ slug = lser.get_slug(extended=True)
+
+ if outdir != '-':
+ am_filename = os.path.join(outdir, f'{slug}.{dftext}')
+ am_cover = os.path.join(outdir, f'{slug}.cover')
+
+ if os.path.exists(am_filename):
+ if os.path.isdir(am_filename):
+ shutil.rmtree(am_filename)
+ else:
+ os.unlink(am_filename)
+ if save_maildir:
+ b4.save_maildir(am_msgs, am_filename)
+ else:
+ with open(am_filename, 'w') as fh:
+ b4.save_git_am_mbox(am_msgs, fh)
+ else:
+ am_cover = None
+ b4.save_git_am_mbox(am_msgs, sys.stdout)
- linkurl = config['linkmask'] % top_msgid
- if cmdargs.quiltready:
- q_dirname = os.path.join(outdir, f'{slug}.patches')
- save_as_quilt(am_msgs, q_dirname)
- logger.critical('Quilt: %s', q_dirname)
+ if lser.has_cover and not cmdargs.nocover:
+ lser.save_cover(am_cover)
- logger.critical(' Link: %s', linkurl)
+ linkurl = config['linkmask'] % top_msgid
+ if cmdargs.quiltready:
+ q_dirname = os.path.join(outdir, f'{slug}.patches')
+ save_as_quilt(am_msgs, q_dirname)
+ logger.critical('Quilt: %s', q_dirname)
+
+ logger.critical(' Link: %s', linkurl)
base_commit = None
- matches = re.search(r'base-commit: .*?([0-9a-f]+)', first_body, re.MULTILINE)
+ matches = re.search(r'base-commit: .*?([\da-f]+)', first_body, re.MULTILINE)
if matches:
base_commit = matches.groups()[0]
else:
# Try a more relaxed search
- matches = re.search(r'based on .*?([0-9a-f]{40})', first_body, re.MULTILINE)
+ matches = re.search(r'based on .*?([\da-f]{40})', first_body, re.MULTILINE)
if matches:
base_commit = matches.groups()[0]
- if base_commit:
- logger.critical(' Base: %s', base_commit)
- else:
- if topdir is not None:
- if cmdargs.guessbase:
- logger.critical(' attempting to guess base-commit...')
+ if not base_commit and topdir and cmdargs.guessbase:
+ logger.critical(' Base: attempting to guess base-commit...')
+ try:
+ base_commit, nblobs, mismatches = lser.find_base(topdir, branches=cmdargs.guessbranch,
+ maxdays=cmdargs.guessdays)
+ if mismatches == 0:
+ logger.critical(' Base: %s (exact match)', base_commit)
+ elif nblobs == mismatches:
+ logger.critical(' Base: failed to guess base')
+ else:
+ logger.critical(' Base: %s (best guess, %s/%s blobs matched)', base_commit,
+ nblobs - mismatches, nblobs)
+ except IndexError:
+ logger.critical(' Base: failed to guess base')
+
+ if cmdargs.subcmd == 'shazam':
+ if not topdir:
+ logger.critical('Could not figure out where your git dir is, cannot shazam.')
+ sys.exit(1)
+ ifh = io.StringIO()
+ b4.save_git_am_mbox(am_msgs, ifh)
+ ambytes = ifh.getvalue().encode()
+ if not cmdargs.makefetchhead:
+ amflags = config.get('shazam-am-flags', '')
+ sp = shlex.shlex(amflags, posix=True)
+ sp.whitespace_split = True
+ amargs = list(sp)
+ ecode, out = b4.git_run_command(topdir, ['am'] + amargs, stdin=ambytes, logstderr=True)
+ logger.info(out.strip())
+ if ecode == 0:
+ thanks_record_am(lser, cherrypick=cherrypick)
+ sys.exit(ecode)
+
+ if not base_commit:
+ # Try our best with HEAD, I guess
+ base_commit = 'HEAD'
+
+ with b4.git_temp_worktree(topdir, base_commit) as gwt:
+ logger.info('Magic: Preparing a sparse worktree')
+ ecode, out = b4.git_run_command(gwt, ['sparse-checkout', 'init'], logstderr=True)
+ if ecode > 0:
+ logger.critical('Error running sparse-checkout init')
+ logger.critical(out)
+ sys.exit(ecode)
+ ecode, out = b4.git_run_command(gwt, ['checkout'], logstderr=True)
+ if ecode > 0:
+ logger.critical('Error running checkout into sparse workdir')
+ logger.critical(out)
+ sys.exit(ecode)
+ ecode, out = b4.git_run_command(gwt, ['am'], stdin=ambytes, logstderr=True)
+ if ecode > 0:
+ logger.critical('Unable to cleanly apply series, see failure log below')
+ logger.critical('---')
+ logger.critical(out.strip())
+ logger.critical('---')
+ logger.critical('Not fetching into FETCH_HEAD')
+ sys.exit(ecode)
+ logger.info('---')
+ logger.info(out.strip())
+ logger.info('---')
+ logger.info('Fetching into FETCH_HEAD')
+ gitargs = ['fetch', gwt]
+ ecode, out = b4.git_run_command(topdir, gitargs, logstderr=True)
+ if ecode > 0:
+ logger.critical('Unable to fetch from the worktree')
+ logger.critical(out.strip())
+ sys.exit(ecode)
+ gitargs = ['rev-parse', '--git-path', 'FETCH_HEAD']
+ ecode, fhf = b4.git_run_command(topdir, gitargs, logstderr=True)
+ if ecode > 0:
+ logger.critical('Unable to find FETCH_HEAD')
+ logger.critical(out.strip())
+ sys.exit(ecode)
+ with open(fhf.rstrip(), 'r') as fhh:
+ contents = fhh.read()
+ linkurl = config['linkmask'] % top_msgid
+ if len(am_msgs) > 1:
+ mmsg = 'patches from %s' % linkurl
+ else:
+ mmsg = 'patch from %s' % linkurl
+ new_contents = contents.replace(gwt, mmsg)
+ if new_contents != contents:
+ with open(fhf, 'w') as fhh:
+ fhh.write(new_contents)
+
+ gitargs = ['rev-parse', '--git-dir']
+ ecode, fhf = b4.git_run_command(topdir, gitargs, logstderr=True)
+ if ecode > 0:
+ logger.critical('Unable to find git directory')
+ logger.critical(out.strip())
+ sys.exit(ecode)
+ mmf = os.path.join(fhf.rstrip(), 'b4-cover')
+ merge_template = DEFAULT_MERGE_TEMPLATE
+ if config.get('shazam-merge-template'):
+ # Try to load this template instead
try:
- base_commit, nblobs, mismatches = lser.find_base(topdir, branches=cmdargs.guessbranch,
- maxdays=cmdargs.guessdays)
- if mismatches == 0:
- logger.critical(' Base: %s (exact match)', base_commit)
- elif nblobs == mismatches:
- logger.critical(' Base: failed to guess base')
- else:
- logger.critical(' Base: %s (best guess, %s/%s blobs matched)', base_commit,
- nblobs - mismatches, nblobs)
- except IndexError:
- logger.critical(' Base: failed to guess base')
+ merge_template = b4.read_template(config['shazam-merge-template'])
+ except FileNotFoundError:
+ logger.critical('ERROR: shazam-merge-template says to use %s, but it does not exist',
+ config['shazam-merge-template'])
+ sys.exit(2)
+
+ # Write out a sample merge message using the cover letter
+ if os.path.exists(mmf):
+ # Make sure any old cover letters don't confuse anyone
+ os.unlink(mmf)
+
+ if lser.has_cover:
+ cmsg = lser.patches[0]
+ parts = b4.LoreMessage.get_body_parts(cmsg.body)
+ covermessage = parts[1]
else:
- checked, mismatches = lser.check_applies_clean(topdir, at=cmdargs.guessbranch)
- if checked and len(mismatches) == 0 and checked != mismatches:
- logger.critical(' Base: applies clean to current tree')
- else:
- logger.critical(' Base: not specified')
+ cmsg = lser.patches[1]
+ covermessage = ('NOTE: No cover letter provided by the author.\n'
+ ' Add merge commit message here.')
+ tptvals = {
+ 'seriestitle': cmsg.subject,
+ 'authorname': cmsg.fromname,
+ 'authoremail': cmsg.fromemail,
+ 'covermessage': covermessage,
+ 'midurl': linkurl,
+ }
+ if len(am_msgs) > 1:
+ tptvals['patch_or_series'] = 'patch series'
+ else:
+ tptvals['patch_or_series'] = 'patch'
+
+ body = Template(merge_template).safe_substitute(tptvals)
+ with open(mmf, 'w') as mmh:
+ mmh.write(body)
+
+ mergeflags = config.get('shazam-merge-flags', '--signoff')
+ sp = shlex.shlex(mergeflags, posix=True)
+ sp.whitespace_split = True
+ mergeargs = ['merge', '--no-ff', '-F', mmf, '--edit', 'FETCH_HEAD'] + list(sp)
+ mergecmd = ['git'] + mergeargs
+
+ thanks_record_am(lser, cherrypick=cherrypick)
+ if cmdargs.merge:
+ if not cmdargs.no_interactive:
+ logger.info('Will exec: %s', ' '.join(mergecmd))
+ try:
+ input('Press Enter to continue or Ctrl-C to abort')
+ except KeyboardInterrupt:
+ logger.info('')
+ sys.exit(130)
+ else:
+ logger.info('Invoking: %s', ' '.join(mergecmd))
+ # We exec git-merge and let it take over
+ os.execvp(mergecmd[0], mergecmd)
+
+ logger.info('You can now merge or checkout FETCH_HEAD')
+ logger.info(' e.g.: %s', ' '.join(mergecmd))
+ return
+
+ if not base_commit:
+ checked, mismatches = lser.check_applies_clean(topdir, at=cmdargs.guessbranch)
+ if checked and len(mismatches) == 0 and checked != mismatches:
+ logger.critical(' Base: applies clean to current tree')
+ base_commit = 'HEAD'
else:
logger.critical(' Base: not specified')
@@ -257,7 +407,6 @@ def make_am(msgs, cmdargs, msgid):
logger.critical(' git checkout -b %s %s', gitbranch, base_commit)
if cmdargs.outdir != '-':
logger.critical(' git am %s', am_filename)
-
thanks_record_am(lser, cherrypick=cherrypick)
@@ -268,6 +417,7 @@ def thanks_record_am(lser, cherrypick=None):
filename = '%s.am' % slug
patches = list()
+ msgids = list()
at = 0
padlen = len(str(lser.expected))
lmsg = None
@@ -276,6 +426,7 @@ def thanks_record_am(lser, cherrypick=None):
if pmsg is None:
at += 1
continue
+ msgids.append(pmsg.msgid)
if lmsg is None:
lmsg = pmsg
@@ -305,6 +456,7 @@ def thanks_record_am(lser, cherrypick=None):
allto = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('to', [])])
allcc = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('cc', [])])
+ # TODO: check for reply-to and x-original-from
out = {
'msgid': lmsg.msgid,
'subject': lmsg.full_subject,
@@ -323,6 +475,11 @@ def thanks_record_am(lser, cherrypick=None):
json.dump(out, fh, ensure_ascii=False, indent=4)
logger.debug('Wrote %s for thanks tracking', filename)
+ config = b4.get_main_config()
+ pwstate = config.get('pw-review-state')
+ if pwstate:
+ b4.patchwork_set_state(msgids, pwstate)
+
def save_as_quilt(am_msgs, q_dirname):
if os.path.exists(q_dirname):
@@ -366,12 +523,12 @@ def get_extra_series(msgs: list, direction: int = 1, wantvers: Optional[int] = N
seen_msgids.add(msgid)
lsub = b4.LoreSubject(msg['Subject'])
if direction > 0 and lsub.reply:
- # Does it have an "Obsoleted-by: trailer?
+ # Does it have an Obsoleted-by: trailer?
rmsg = b4.LoreMessage(msg)
trailers, mismatches = rmsg.get_trailers()
- for tl in trailers:
- if tl[0].lower() == 'obsoleted-by':
- for chunk in tl[1].split('/'):
+ for ltr in trailers:
+ if ltr.lname == 'obsoleted-by':
+ for chunk in ltr.value.split('/'):
if chunk.find('@') > 0 and chunk not in seen_msgids:
obsoleted.append(chunk)
break
@@ -521,7 +678,7 @@ def get_extra_series(msgs: list, direction: int = 1, wantvers: Optional[int] = N
return msgs
-def get_msgs(cmdargs) -> Tuple[Optional[str], Optional[list]]:
+def get_msgs(cmdargs: argparse.Namespace) -> Tuple[Optional[str], Optional[list]]:
msgid = None
if not cmdargs.localmbox:
msgid = b4.get_msgid(cmdargs)
@@ -530,12 +687,9 @@ def get_msgs(cmdargs) -> Tuple[Optional[str], Optional[list]]:
sys.exit(1)
pickings = set()
- try:
- if cmdargs.cherrypick == '_':
- # Just that msgid, please
- pickings = {msgid}
- except AttributeError:
- pass
+ if 'cherrypick' in cmdargs and cmdargs.cherrypick == '_':
+ # Just that msgid, please
+ pickings = {msgid}
msgs = b4.get_pi_thread_by_msgid(msgid, useproject=cmdargs.useproject, nocache=cmdargs.nocache,
onlymsgids=pickings)
if not msgs:
@@ -567,6 +721,9 @@ def get_msgs(cmdargs) -> Tuple[Optional[str], Optional[list]]:
logger.critical('Mailbox %s does not exist', cmdargs.localmbox)
sys.exit(1)
+ if msgid and 'noparent' in cmdargs and cmdargs.noparent:
+ msgs = b4.get_strict_thread(msgs, msgid, noparent=True)
+
if not msgid and msgs:
for msg in msgs:
msgid = msg.get('Message-ID', None)
@@ -578,6 +735,20 @@ def get_msgs(cmdargs) -> Tuple[Optional[str], Optional[list]]:
def main(cmdargs):
+ if cmdargs.subcmd == 'shazam':
+ # We force some settings
+ cmdargs.checknewer = True
+ cmdargs.threeway = False
+ cmdargs.nopartialreroll = False
+ cmdargs.outdir = '-'
+ cmdargs.guessbranch = None
+ if cmdargs.merge:
+ cmdargs.makefetchhead = True
+ if cmdargs.makefetchhead:
+ cmdargs.guessbase = True
+ else:
+ cmdargs.guessbase = False
+
if cmdargs.checknewer:
# Force nocache mode
cmdargs.nocache = True
@@ -589,7 +760,7 @@ def main(cmdargs):
if len(msgs) and cmdargs.checknewer:
msgs = get_extra_series(msgs, direction=1, useproject=cmdargs.useproject)
- if cmdargs.subcmd == 'am':
+ if cmdargs.subcmd in ('am', 'shazam'):
make_am(msgs, cmdargs, msgid)
return
diff --git a/b4/pr.py b/b4/pr.py
index 5a66180..596d3ad 100644
--- a/b4/pr.py
+++ b/b4/pr.py
@@ -32,12 +32,12 @@ charset.add_charset('utf-8', None)
logger = b4.logger
PULL_BODY_SINCE_ID_RE = [
- re.compile(r'changes since commit ([0-9a-f]{5,40}):', re.M | re.I)
+ re.compile(r'changes since commit ([\da-f]{5,40}):', re.M | re.I)
]
# I like these
PULL_BODY_WITH_COMMIT_ID_RE = [
- re.compile(r'fetch changes up to ([0-9a-f]{5,40}):', re.M | re.I),
+ re.compile(r'fetch changes up to ([\da-f]{5,40}):', re.M | re.I),
]
# I don't like these
@@ -256,6 +256,11 @@ def thanks_record_pr(lmsg):
json.dump(out, fh, ensure_ascii=False, indent=4)
logger.debug('Wrote %s for thanks tracking', filename)
+ config = b4.get_main_config()
+ pwstate = config.get('pw-review-state')
+ if pwstate:
+ b4.patchwork_set_state(lmsg.msgid, pwstate)
+
def explode(gitdir, lmsg, mailfrom=None, retrieve_links=True, fpopts=None):
ecode = fetch_remote(gitdir, lmsg, check_sig=False, ty_track=False)
@@ -286,7 +291,7 @@ def explode(gitdir, lmsg, mailfrom=None, retrieve_links=True, fpopts=None):
if prefix.lower() not in ('git', 'pull'):
prefixes.append(prefix)
- # get our to's and cc's
+ # get our To's and CC's
allto = utils.getaddresses(lmsg.msg.get_all('to', []))
allcc = utils.getaddresses(lmsg.msg.get_all('cc', []))
@@ -313,84 +318,78 @@ def explode(gitdir, lmsg, mailfrom=None, retrieve_links=True, fpopts=None):
# of the archived threads.
linked_ids.add(lmsg.msgid)
- with b4.git_format_patches(gitdir, lmsg.pr_base_commit, 'FETCH_HEAD', prefixes=prefixes, extraopts=fpopts) as pdir:
- if pdir is None:
- raise RuntimeError('Could not run format-patches')
-
- for msgfile in sorted(os.listdir(pdir)):
- with open(os.path.join(pdir, msgfile), 'rb') as fh:
- msg = email.message_from_binary_file(fh)
-
- msubj = b4.LoreSubject(msg.get('subject', ''))
-
- # Is this the cover letter?
- if msubj.counter == 0:
- # We rebuild the message from scratch
- # The cover letter body is the pull request body, plus a few trailers
- body = '%s\n\nbase-commit: %s\nPR-Link: %s\n' % (
- lmsg.body.strip(), lmsg.pr_base_commit, config['linkmask'] % lmsg.msgid)
-
- # Make it a multipart if we're doing retrieve_links
- if retrieve_links:
- cmsg = MIMEMultipart()
- cmsg.attach(MIMEText(body, 'plain'))
- else:
- cmsg = email.message.EmailMessage()
- cmsg.set_payload(body)
+ # FIXME: This is currently broken due to changes to git_range_to_patches
+ msgs = b4.git_range_to_patches(gitdir, lmsg.pr_base_commit, 'FETCH_HEAD', with_cover=True,
+ prefixes=prefixes, extraopts=fpopts)
+ for msg in msgs:
+ msubj = b4.LoreSubject(msg.get('subject', ''))
+
+ # Is this the cover letter?
+ if msubj.counter == 0:
+ # We rebuild the message from scratch
+ # The cover letter body is the pull request body, plus a few trailers
+ body = '%s\n\nbase-commit: %s\nPR-Link: %s\n' % (
+ lmsg.body.strip(), lmsg.pr_base_commit, config['linkmask'] % lmsg.msgid)
+
+ # Make it a multipart if we're doing retrieve_links
+ if retrieve_links:
+ cmsg = MIMEMultipart()
+ cmsg.attach(MIMEText(body, 'plain'))
+ else:
+ cmsg = email.message.EmailMessage()
+ cmsg.set_payload(body)
- cmsg.add_header('From', mailfrom)
- cmsg.add_header('Subject', '[' + ' '.join(msubj.prefixes) + '] ' + lmsg.subject)
- cmsg.add_header('Date', lmsg.msg.get('Date'))
- cmsg.set_charset('utf-8')
- cmsg.replace_header('Content-Transfer-Encoding', '8bit')
+ cmsg.add_header('From', mailfrom)
+ cmsg.add_header('Subject', '[' + ' '.join(msubj.prefixes) + '] ' + lmsg.subject)
+ cmsg.add_header('Date', lmsg.msg.get('Date'))
+ cmsg.set_charset('utf-8')
+ cmsg.replace_header('Content-Transfer-Encoding', '8bit')
- msg = cmsg
+ msg = cmsg
- else:
- # Move the original From and Date into the body
- prepend = list()
- if msg.get('From') != mailfrom:
- cleanfrom = b4.LoreMessage.clean_header(msg['from'])
- prepend.append('From: %s' % ''.join(cleanfrom))
- msg.replace_header('From', mailfrom)
-
- prepend.append('Date: %s' % msg['date'])
- body = '%s\n\n%s' % ('\n'.join(prepend), msg.get_payload(decode=True).decode('utf-8'))
- msg.set_payload(body)
- msg.replace_header('Subject', msubj.full_subject)
-
- if retrieve_links:
- matches = re.findall(r'^Link:\s+https?://.*/(\S+@\S+)[^/]', body, flags=re.M | re.I)
- if matches:
- linked_ids.update(matches)
- matches = re.findall(r'^Message-ID:\s+(\S+@\S+)', body, flags=re.M | re.I)
- if matches:
- linked_ids.update(matches)
-
- # Add a number of seconds equalling the counter, in hopes it gets properly threaded
- newdate = lmsg.date + timedelta(seconds=msubj.counter)
- msg.replace_header('Date', utils.format_datetime(newdate))
-
- # Thread it to the cover letter
- msg.add_header('In-Reply-To', '<b4-exploded-0-%s>' % lmsg.msgid)
- msg.add_header('References', '<b4-exploded-0-%s>' % lmsg.msgid)
-
- msg.add_header('To', format_addrs(allto))
- if allcc:
- msg.add_header('Cc', format_addrs(allcc))
-
- # Set the message-id based on the original pull request msgid
- msg.add_header('Message-Id', '<b4-exploded-%s-%s>' % (msubj.counter, lmsg.msgid))
-
- if mailfrom != lmsg.msg.get('From'):
- msg.add_header('Reply-To', lmsg.msg.get('From'))
- msg.add_header('X-Original-From', lmsg.msg.get('From'))
-
- if lmsg.msg['List-Id']:
- msg.add_header('X-Original-List-Id', b4.LoreMessage.clean_header(lmsg.msg['List-Id']))
- logger.info(' %s', msg.get('Subject'))
- msg.set_charset('utf-8')
- msgs.append(msg)
+ else:
+ # Move the original From and Date into the body
+ prepend = list()
+ if msg.get('From') != mailfrom:
+ cleanfrom = b4.LoreMessage.clean_header(msg['from'])
+ prepend.append('From: %s' % ''.join(cleanfrom))
+ msg.replace_header('From', mailfrom)
+
+ prepend.append('Date: %s' % msg['date'])
+ body = '%s\n\n%s' % ('\n'.join(prepend), msg.get_payload(decode=True).decode('utf-8'))
+ msg.set_payload(body)
+ msg.replace_header('Subject', msubj.full_subject)
+
+ if retrieve_links:
+ matches = re.findall(r'^Link:\s+https?://.*/(\S+@\S+)[^/]', body, flags=re.M | re.I)
+ if matches:
+ linked_ids.update(matches)
+ matches = re.findall(r'^Message-ID:\s+(\S+@\S+)', body, flags=re.M | re.I)
+ if matches:
+ linked_ids.update(matches)
+
+ # Add a number of seconds equalling the counter, in hopes it gets properly threaded
+ newdate = lmsg.date + timedelta(seconds=msubj.counter)
+ msg.replace_header('Date', utils.format_datetime(newdate))
+
+ # Thread it to the cover letter
+ msg.add_header('In-Reply-To', '<b4-exploded-0-%s>' % lmsg.msgid)
+ msg.add_header('References', '<b4-exploded-0-%s>' % lmsg.msgid)
+
+ msg.add_header('To', format_addrs(allto))
+ if allcc:
+ msg.add_header('Cc', format_addrs(allcc))
+
+ # Set the message-id based on the original pull request msgid
+ msg.add_header('Message-Id', '<b4-exploded-%s-%s>' % (msubj.counter, lmsg.msgid))
+
+ if mailfrom != lmsg.msg.get('From'):
+ msg.add_header('Reply-To', lmsg.msg.get('From'))
+ msg.add_header('X-Original-From', lmsg.msg.get('From'))
+
+ if lmsg.msg['List-Id']:
+ msg.add_header('X-Original-List-Id', b4.LoreMessage.clean_header(lmsg.msg['List-Id']))
+ logger.info(' %s', msg.get('Subject'))
logger.info('Exploded %s messages', len(msgs))
if retrieve_links and linked_ids:
@@ -443,7 +442,7 @@ def get_pr_from_github(ghurl: str):
rpull = chunks[-1]
apiurl = f'https://api.github.com/repos/{rproj}/{rrepo}/pulls/{rpull}'
req = requests.session()
- # Do we have a github API key?
+ # Do we have a GitHub API key?
config = b4.get_main_config()
ghkey = config.get('gh-api-key')
if ghkey:
diff --git a/b4/ty.py b/b4/ty.py
index c6d13cb..2bb7d2f 100644
--- a/b4/ty.py
+++ b/b4/ty.py
@@ -7,10 +7,12 @@ __author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
import os
import sys
+
import b4
import re
import email
import email.message
+import email.policy
import json
from string import Template
@@ -72,28 +74,27 @@ def git_get_commit_message(gitdir, rev):
return b4.git_run_command(gitdir, args)
-def make_reply(reply_template, jsondata):
+def make_reply(reply_template, jsondata, gitdir):
body = Template(reply_template).safe_substitute(jsondata)
# Conform to email standards
body = body.replace('\n', '\r\n')
msg = email.message_from_string(body)
msg['From'] = '%s <%s>' % (jsondata['myname'], jsondata['myemail'])
- allto = utils.getaddresses([jsondata['to']])
- allcc = utils.getaddresses([jsondata['cc']])
- # Remove ourselves and original sender from allto or allcc
- for entry in list(allto):
- if entry[1] == jsondata['myemail'] or entry[1] == jsondata['fromemail']:
- allto.remove(entry)
- for entry in list(allcc):
- if entry[1] == jsondata['myemail'] or entry[1] == jsondata['fromemail']:
- allcc.remove(entry)
-
- # Add original sender to the To
- allto.append((jsondata['fromname'], jsondata['fromemail']))
-
- msg['To'] = b4.format_addrs(allto)
+ excludes = b4.get_excluded_addrs()
+ newto = b4.cleanup_email_addrs([(jsondata['fromname'], jsondata['fromemail'])], excludes, gitdir)
+
+ # Exclude ourselves and original sender from allto or allcc
+ excludes.add(jsondata['myemail'])
+ excludes.add(jsondata['fromemail'])
+ allto = b4.cleanup_email_addrs(utils.getaddresses([jsondata['to']]), excludes, gitdir)
+ allcc = b4.cleanup_email_addrs(utils.getaddresses([jsondata['cc']]), excludes, gitdir)
+
+ if newto:
+ allto += newto
+
+ msg.add_header('To', b4.format_addrs(allto))
if allcc:
- msg['Cc'] = b4.format_addrs(allcc)
+ msg.add_header('Cc', b4.format_addrs(allcc))
msg['In-Reply-To'] = '<%s>' % jsondata['msgid']
if len(jsondata['references']):
msg['References'] = '%s <%s>' % (jsondata['references'], jsondata['msgid'])
@@ -102,9 +103,9 @@ def make_reply(reply_template, jsondata):
subject = re.sub(r'^Re:\s+', '', jsondata['subject'], flags=re.I)
if jsondata.get('cherrypick'):
- msg['Subject'] = 'Re: (subset) ' + subject
+ msg.add_header('Subject', 'Re: (subset) ' + subject)
else:
- msg['Subject'] = 'Re: ' + subject
+ msg.add_header('Subject', 'Re: ' + subject)
mydomain = jsondata['myemail'].split('@')[1]
msg['Message-Id'] = email.utils.make_msgid(idstring='b4-ty', domain=mydomain)
@@ -228,21 +229,6 @@ def auto_locate_series(gitdir, jsondata, branch, since='1.week'):
return found
-def read_template(tptfile):
- # bubbles up FileNotFound
- tpt = ''
- if tptfile.find('~') >= 0:
- tptfile = os.path.expanduser(tptfile)
- if tptfile.find('$') >= 0:
- tptfile = os.path.expandvars(tptfile)
- with open(tptfile, 'r', encoding='utf-8') as fh:
- for line in fh:
- if len(line) and line[0] == '#':
- continue
- tpt += line
- return tpt
-
-
def set_branch_details(gitdir, branch, jsondata, config):
binfo = get_branch_info(gitdir, branch)
jsondata['branch'] = branch
@@ -282,7 +268,7 @@ def generate_pr_thanks(gitdir, jsondata, branch):
if config['thanks-pr-template']:
# Try to load this template instead
try:
- thanks_template = read_template(config['thanks-pr-template'])
+ thanks_template = b4.read_template(config['thanks-pr-template'])
except FileNotFoundError:
logger.critical('ERROR: thanks-pr-template says to use %s, but it does not exist',
config['thanks-pr-template'])
@@ -300,7 +286,7 @@ def generate_pr_thanks(gitdir, jsondata, branch):
if not cidmask:
cidmask = 'merge commit: %s'
jsondata['summary'] = cidmask % jsondata['merge_commit_id']
- msg = make_reply(thanks_template, jsondata)
+ msg = make_reply(thanks_template, jsondata, gitdir)
return msg
@@ -311,7 +297,7 @@ def generate_am_thanks(gitdir, jsondata, branch, since):
if config['thanks-am-template']:
# Try to load this template instead
try:
- thanks_template = read_template(config['thanks-am-template'])
+ thanks_template = b4.read_template(config['thanks-am-template'])
except FileNotFoundError:
logger.critical('ERROR: thanks-am-template says to use %s, but it does not exist',
config['thanks-am-template'])
@@ -348,7 +334,7 @@ def generate_am_thanks(gitdir, jsondata, branch, since):
nomatch, len(commits), jsondata['subject'])
logger.critical(' Please review the resulting message')
- msg = make_reply(thanks_template, jsondata)
+ msg = make_reply(thanks_template, jsondata, gitdir)
return msg
@@ -389,35 +375,38 @@ def auto_thankanator(cmdargs):
sys.exit(0)
logger.info('---')
- send_messages(applied, cmdargs.gitdir, cmdargs.outdir, wantbranch, since=cmdargs.since)
+ send_messages(applied, wantbranch, cmdargs)
sys.exit(0)
-def send_messages(listing, gitdir, outdir, branch, since='1.week'):
- # Not really sending, but writing them out to be sent on your own
- # We'll probably gain ability to send these once the feature is
- # more mature and we're less likely to mess things up
- datadir = b4.get_data_dir()
+def send_messages(listing, branch, cmdargs):
logger.info('Generating %s thank-you letters', len(listing))
- # Check if the outdir exists and if it has any .thanks files in it
- if not os.path.exists(outdir):
- os.mkdir(outdir)
+ gitdir = cmdargs.gitdir
+ datadir = b4.get_data_dir()
+ fromaddr = None
+ smtp = None
+ if cmdargs.sendemail:
+ # See if we have sendemail-identity set
+ config = b4.get_main_config()
+ identity = config.get('sendemail-identity')
+ try:
+ smtp, fromaddr = b4.get_smtp(identity)
+ except Exception as ex: # noqa
+ logger.critical('Failed to configure the smtp connection:')
+ logger.critical(ex)
+ sys.exit(1)
+ else:
+ # We write .thanks notes
+ # Check if the outdir exists and if it has any .thanks files in it
+ if not os.path.exists(cmdargs.outdir):
+ os.mkdir(cmdargs.outdir)
usercfg = b4.get_user_config()
- # Do we have a .signature file?
- sigfile = os.path.join(str(Path.home()), '.signature')
- if os.path.exists(sigfile):
- with open(sigfile, 'r', encoding='utf-8') as fh:
- signature = fh.read()
- else:
- signature = '%s <%s>' % (usercfg['name'], usercfg['email'])
+ signature = b4.get_email_signature()
outgoing = 0
+ msgids = list()
for jsondata in listing:
- slug_from = re.sub(r'\W', '_', jsondata['fromemail'])
- slug_subj = re.sub(r'\W', '_', jsondata['subject'])
- slug = '%s_%s' % (slug_from.lower(), slug_subj.lower())
- slug = re.sub(r'_+', '_', slug)
jsondata['myname'] = usercfg['name']
jsondata['myemail'] = usercfg['email']
jsondata['signature'] = signature
@@ -426,29 +415,65 @@ def send_messages(listing, gitdir, outdir, branch, since='1.week'):
msg = generate_pr_thanks(gitdir, jsondata, branch)
else:
# This is a patch series
- msg = generate_am_thanks(gitdir, jsondata, branch, since)
+ msg = generate_am_thanks(gitdir, jsondata, branch, cmdargs.since)
if msg is None:
continue
+ msgids.append(jsondata['msgid'])
+ for pdata in jsondata.get('patches', list()):
+ msgids.append(pdata[2])
+
outgoing += 1
- outfile = os.path.join(outdir, '%s.thanks' % slug)
- logger.info(' Writing: %s', outfile)
msg.set_charset('utf-8')
msg.replace_header('Content-Transfer-Encoding', '8bit')
- with open(outfile, 'w') as fh:
- fh.write(msg.as_string(policy=b4.emlpolicy))
- logger.debug('Cleaning up: %s', jsondata['trackfile'])
- fullpath = os.path.join(datadir, jsondata['trackfile'])
- os.rename(fullpath, '%s.sent' % fullpath)
+ if cmdargs.sendemail:
+ if not fromaddr:
+ fromaddr = jsondata['myemail']
+ logger.info(' Sending: %s', msg.get('subject'))
+ # We never want to use the web endpoint for this (it's only for submitting patches)
+ b4.send_mail(smtp, [msg], fromaddr, dryrun=cmdargs.dryrun, use_web_endpoint=False)
+ else:
+ slug_from = re.sub(r'\W', '_', jsondata['fromemail'])
+ slug_subj = re.sub(r'\W', '_', jsondata['subject'])
+ slug = '%s_%s' % (slug_from.lower(), slug_subj.lower())
+ slug = re.sub(r'_+', '_', slug)
+ outfile = os.path.join(cmdargs.outdir, '%s.thanks' % slug)
+ logger.info(' Writing: %s', outfile)
+ with open(outfile, 'w') as fh:
+ fh.write(msg.as_string(policy=b4.emlpolicy))
+ if cmdargs.dryrun:
+ logger.info('Dry run, preserving tracked series.')
+ else:
+ logger.debug('Cleaning up: %s', jsondata['trackfile'])
+ fullpath = os.path.join(datadir, jsondata['trackfile'])
+ os.rename(fullpath, '%s.sent' % fullpath)
+
logger.info('---')
if not outgoing:
logger.info('No thanks necessary.')
return
- logger.debug('Wrote %s thank-you letters', outgoing)
- logger.info('You can now run:')
- logger.info(' git send-email %s/*.thanks', outdir)
+ config = b4.get_main_config()
+ pwstate = cmdargs.pw_set_state
+ if not pwstate:
+ pwstate = config.get('pw-accept-state')
+
+ if cmdargs.sendemail:
+ if cmdargs.dryrun:
+ logger.info('DRYRUN: generated %s thank-you letters', outgoing)
+ else:
+ logger.info('Sent %s thank-you letters', outgoing)
+ if pwstate:
+ b4.patchwork_set_state(msgids, pwstate)
+ smtp.quit()
+ else:
+ if pwstate:
+ b4.patchwork_set_state(msgids, pwstate)
+ logger.info('---')
+ logger.debug('Wrote %s thank-you letters', outgoing)
+ logger.info('You can now run:')
+ logger.info(' git send-email %s/*.thanks', cmdargs.outdir)
def list_tracked():
@@ -480,17 +505,17 @@ def write_tracked(tracked):
counter += 1
-def send_selected(cmdargs):
+def thank_selected(cmdargs):
tracked = list_tracked()
if not len(tracked):
logger.info('Nothing to do')
sys.exit(0)
- if cmdargs.send == 'all':
+ if cmdargs.thankfor == 'all':
listing = tracked
else:
listing = list()
- for num in b4.parse_int_range(cmdargs.send, upper=len(tracked)):
+ for num in b4.parse_int_range(cmdargs.thankfor, upper=len(tracked)):
try:
index = int(num) - 1
listing.append(tracked[index])
@@ -509,7 +534,7 @@ def send_selected(cmdargs):
sys.exit(0)
wantbranch = get_wanted_branch(cmdargs)
- send_messages(listing, cmdargs.gitdir, cmdargs.outdir, wantbranch, cmdargs.since)
+ send_messages(listing, wantbranch, cmdargs)
sys.exit(0)
@@ -544,10 +569,21 @@ def discard_selected(cmdargs):
datadir = b4.get_data_dir()
logger.info('Discarding %s messages', len(listing))
+ msgids = list()
for jsondata in listing:
fullpath = os.path.join(datadir, jsondata['trackfile'])
os.rename(fullpath, '%s.discarded' % fullpath)
logger.info(' Discarded: %s', jsondata['subject'])
+ msgids.append(jsondata['msgid'])
+ for pdata in jsondata.get('patches', list()):
+ msgids.append(pdata[2])
+
+ config = b4.get_main_config()
+ pwstate = cmdargs.pw_set_state
+ if not pwstate:
+ pwstate = config.get('pw-discard-state')
+ if pwstate:
+ b4.patchwork_set_state(msgids, pwstate)
sys.exit(0)
@@ -636,9 +672,9 @@ def main(cmdargs):
if cmdargs.auto:
check_stale_thanks(cmdargs.outdir)
auto_thankanator(cmdargs)
- elif cmdargs.send:
+ elif cmdargs.thankfor:
check_stale_thanks(cmdargs.outdir)
- send_selected(cmdargs)
+ thank_selected(cmdargs)
elif cmdargs.discard:
discard_selected(cmdargs)
else:
@@ -649,4 +685,4 @@ def main(cmdargs):
write_tracked(tracked)
logger.info('---')
logger.info('You can send them using number ranges, e.g:')
- logger.info(' b4 ty -s 1-3,5,7-')
+ logger.info(' b4 ty -t 1-3,5,7-')
diff --git a/man/b4.5 b/man/b4.5
index de44247..bf50ed7 100644
--- a/man/b4.5
+++ b/man/b4.5
@@ -1,8 +1,5 @@
.\" Man page generated from reStructuredText.
.
-.TH B4 5 "2020-11-20" "0.7.0" ""
-.SH NAME
-B4 \- Work with code submissions in a public-inbox archive
.
.nr rst2man-indent-level 0
.
@@ -30,9 +27,12 @@ level margin: \\n[rst2man-indent\\n[rst2man-indent-level]]
.\" new: \\n[rst2man-indent\\n[rst2man-indent-level]]
.in \\n[rst2man-indent\\n[rst2man-indent-level]]u
..
+.TH "B4" 5 "2022-06-16" "0.9.0" ""
+.SH NAME
+B4 \- Work with code submissions in a public-inbox archive
.SH SYNOPSIS
.sp
-b4 {mbox,am,attest,pr,ty,diff} [options]
+b4 {mbox,am,shazam,pr,diff,ty,kr} [options]
.SH DESCRIPTION
.sp
This is a helper utility to work with patches and pull requests made
@@ -45,166 +45,283 @@ precursor to Lore and Data in the Star Trek universe.
.SH SUBCOMMANDS
.INDENT 0.0
.IP \(bu 2
-\fIb4 mbox\fP: Download a thread as an mbox file
+\fImbox\fP: Download a thread as an mbox file
.IP \(bu 2
-\fIb4 am\fP: Create an mbox file that is ready to git\-am
+\fIam\fP: Create an mbox file that is ready to git\-am
.IP \(bu 2
-\fIb4 pr\fP: Work with pull requests
+\fIshazam\fP: Apply patch series to git repositories
.IP \(bu 2
-\fIb4 diff\fP: Show range\-diff style diffs between patch versions
+\fIpr\fP: Work with pull requests
.IP \(bu 2
-\fIb4 ty\fP: Create templated replies for processed patches and pull requests
+\fIdiff\fP: Show range\-diff style diffs between patch versions
.IP \(bu 2
-\fIb4 attest\fP: (EXPERIMENTAL) Add cryptographic attestation to patches
+\fIty\fP: Create templated replies for processed patches and pull requests
.IP \(bu 2
-\fIb4 kr\fP (EXPERIMENTAL) Operate on patatt\-compatible keyrings
+\fIkr\fP (EXPERIMENTAL) Operate on patatt\-compatible keyrings
.UNINDENT
.SH OPTIONS
.INDENT 0.0
.TP
-.B \-h\fP,\fB \-\-help
+.B \-h\fP,\fB \-\-help
show this help message and exit
.TP
-.B \-d\fP,\fB \-\-debug
+.B \-d\fP,\fB \-\-debug
Add more debugging info to the output (default: False)
.TP
-.B \-q\fP,\fB \-\-quiet
+.B \-q\fP,\fB \-\-quiet
Output critical information only (default: False)
+.TP
+.B \-n\fP,\fB \-\-no\-interactive
+Do not ask any interactive questions (default: False)
.UNINDENT
.SH SUBCOMMAND OPTIONS
.SS b4 mbox
+.sp
+This command allows retrieving entire threads from a remote public\-inbox
+instance. The resulting mbox file can then be opened with most MUA
+clients for actions like replying to conversations or reviewing patch
+submissions.
.INDENT 0.0
.TP
.B usage:
-b4 mbox [\-h] [\-o OUTDIR] [\-p USEPROJECT] [\-c] [\-n WANTNAME] [\-m LOCALMBOX] [msgid]
+b4 mbox [\-h] [\-p USEPROJECT] [\-m LOCALMBOX] [\-C] [\-o OUTDIR] [\-c] [\-n WANTNAME] [\-M] [\-f] [msgid]
.TP
.B positional arguments:
msgid Message ID to process, or pipe a raw message
.TP
-.B optional arguments:
+.B options:
.INDENT 7.0
.TP
-.B \-h\fP,\fB \-\-help
+.B \-h\fP,\fB \-\-help
show this help message and exit
.TP
+.BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT
+Use a specific project instead of default (linux\-mm, linux\-hardening, etc)
+.TP
+.BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX
+Instead of grabbing a thread from lore, process this mbox file (or \- for stdin)
+.TP
+.B \-C\fP,\fB \-\-no\-cache
+Do not use local cache
+.TP
.BI \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR
Output into this directory (or use \- to output mailbox contents to stdout)
.TP
-.BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT
-Use a specific project instead of guessing (linux\-mm, linux\-hardening, etc)
-.TP
-.B \-c\fP,\fB \-\-check\-newer\-revisions
+.B \-c\fP,\fB \-\-check\-newer\-revisions
Check if newer patch revisions exist
.TP
.BI \-n \ WANTNAME\fR,\fB \ \-\-mbox\-name \ WANTNAME
-Filename to name the mbox file
-.TP
-.BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX
-Instead of grabbing a thread from lore, process this mbox file
-(or use \- for stdin)
+Filename to name the mbox destination
.TP
-.B \-C\fP,\fB \-\-no\-cache
-Do not use local cache
+.B \-M\fP,\fB \-\-save\-as\-maildir
+Save as maildir (avoids mbox format ambiguities)
.TP
-.B \-f\fP,\fB \-\-filter\-dupes
+.B \-f\fP,\fB \-\-filter\-dupes
When adding messages to existing maildir, filter out duplicates
-.TP
-.B \-M\fP,\fB \-\-save\-as\-maildir
-Save as maildir (avoids mbox format ambiguities)
.UNINDENT
.UNINDENT
.sp
\fIExample\fP: b4 mbox \fI\%20200313231252.64999\-1\-keescook@chromium.org\fP
.SS b4 am
+.sp
+This command allows retrieving threads from a public\-inbox instance and
+preparing them for applying to a git repository using the "git am"
+command. It will automatically perform the following operations:
+.INDENT 0.0
+.IP \(bu 2
+pick the latest submitted version of the series (it can check for
+newer threads using \fB\-c\fP as well)
+.IP \(bu 2
+check DKIM signatures and patatt attestation on all patches and code
+review messages
+.IP \(bu 2
+collate all submitted code\-review trailers (Reviewed\-by, Acked\-by,
+etc) and put them into the commit message
+.IP \(bu 2
+add your own Signed\-off\-by trailer (with \fB\-s\fP)
+.IP \(bu 2
+reroll series from partial updates (e.g. someone submits a v2 of a
+single patch instead of rerolling the entire series)
+.IP \(bu 2
+guess where in the tree history the patches belong, if the exact
+commit\-base is not specified (with \fB\-g\fP)
+.IP \(bu 2
+prepare the tree for a 3\-way merge (with \fB\-3\fP)
+.IP \(bu 2
+cherry\-pick a subset of patches from a large series (with \fB\-P\fP)
+.UNINDENT
.INDENT 0.0
.TP
.B usage:
-b4 am [\-h] [\-o OUTDIR] [\-p USEPROJECT] [\-c] [\-n WANTNAME] [\-m LOCALMBOX] [\-v WANTVER] [\-t] [\-T] [\-s] [\-l] [\-Q] [msgid]
+b4 am [\-h] [\-p USEPROJECT] [\-m LOCALMBOX] [\-C] [\-o OUTDIR] [\-c] [\-n WANTNAME] [\-M] [\-v WANTVER] [\-t] [\-S] [\-T] [\-s] [\-l] [\-P CHERRYPICK] [\-\-cc\-trailers] [\-\-no\-parent] [\-\-allow\-unicode\-control\-chars] [\-Q] [\-g] [\-b GUESSBRANCH [GUESSBRANCH ...]] [\-\-guess\-lookback GUESSDAYS] [\-3] [\-\-no\-cover] [\-\-no\-partial\-reroll] [msgid]
.TP
.B positional arguments:
msgid Message ID to process, or pipe a raw message
.TP
-.B optional arguments:
+.B options:
.INDENT 7.0
.TP
-.B \-h\fP,\fB \-\-help
+.B \-h\fP,\fB \-\-help
show this help message and exit
.TP
+.BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT
+Use a specific project instead of default (linux\-mm, linux\-hardening, etc)
+.TP
+.BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX
+Instead of grabbing a thread from lore, process this mbox file (or \- for stdin)
+.TP
+.B \-C\fP,\fB \-\-no\-cache
+Do not use local cache
+.TP
.BI \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR
Output into this directory (or use \- to output mailbox contents to stdout)
.TP
-.BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT
-Use a specific project instead of guessing (linux\-mm, linux\-hardening, etc)
-.TP
-.B \-c\fP,\fB \-\-check\-newer\-revisions
+.B \-c\fP,\fB \-\-check\-newer\-revisions
Check if newer patch revisions exist
.TP
.BI \-n \ WANTNAME\fR,\fB \ \-\-mbox\-name \ WANTNAME
-Filename to name the mbox file
-.TP
-.BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX
-Instead of grabbing a thread from lore, process this mbox file
-(or use \- for stdin)
+Filename to name the mbox destination
.TP
-.B \-M\fP,\fB \-\-save\-as\-maildir
+.B \-M\fP,\fB \-\-save\-as\-maildir
Save as maildir (avoids mbox format ambiguities)
.TP
-.B \-C\fP,\fB \-\-no\-cache
-Do not use local cache
-.TP
.BI \-v \ WANTVER\fR,\fB \ \-\-use\-version \ WANTVER
Get a specific version of the patch/series
.TP
-.B \-t\fP,\fB \-\-apply\-cover\-trailers
+.B \-t\fP,\fB \-\-apply\-cover\-trailers
Apply trailers sent to the cover letter to all patches
.TP
-.B \-S\fP,\fB \-\-sloppy\-trailers
+.B \-S\fP,\fB \-\-sloppy\-trailers
Apply trailers without email address match checking
.TP
-.B \-T\fP,\fB \-\-no\-add\-trailers
+.B \-T\fP,\fB \-\-no\-add\-trailers
Do not add or sort any trailers
.TP
-.B \-s\fP,\fB \-\-add\-my\-sob
+.B \-s\fP,\fB \-\-add\-my\-sob
Add your own signed\-off\-by to every patch
.TP
-.B \-l\fP,\fB \-\-add\-link
-Add a lore.kernel.org/r/ link to every patch
-.TP
-.B \-Q\fP,\fB \-\-quilt\-ready
-Save patches in a quilt\-ready folder
+.B \-l\fP,\fB \-\-add\-link
+Add a Link: with message\-id lookup URL to every patch
.TP
.BI \-P \ CHERRYPICK\fR,\fB \ \-\-cherry\-pick \ CHERRYPICK
-Cherry\-pick a subset of patches (e.g. "\-P 1\-2,4,6\-", "\-P _" to use just the msgid specified, or "\-P *globbing*" to match on commit subject)
+Cherry\-pick a subset of patches (e.g. "\-P 1\-2,4,6\-", "\-P _" to use just the msgid specified, or "\-P \fIglobbing\fP" to match on commit subject)
.TP
-.B \-g\fP,\fB \-\-guess\-base
+.B \-\-cc\-trailers
+Copy all Cc\(aqd addresses into Cc: trailers
+.TP
+.B \-\-no\-parent
+Break thread at the msgid specified and ignore any parent messages
+.TP
+.B \-\-allow\-unicode\-control\-chars
+Allow unicode control characters (very rarely legitimate)
+.TP
+.B \-Q\fP,\fB \-\-quilt\-ready
+Save patches in a quilt\-ready folder
+.TP
+.B \-g\fP,\fB \-\-guess\-base
Try to guess the base of the series (if not specified)
+.UNINDENT
+.INDENT 7.0
.TP
-.B \-3\fP,\fB \-\-prep\-3way
-Prepare for a 3\-way merge (tries to ensure that all index blobs exist by making a fake commit range)
+.B \-b GUESSBRANCH [GUESSBRANCH ...], \-\-guess\-branch GUESSBRANCH [GUESSBRANCH ...]
+When guessing base, restrict to this branch (use with \-g)
+.UNINDENT
+.INDENT 7.0
+.TP
+.BI \-\-guess\-lookback \ GUESSDAYS
+When guessing base, go back this many days from the patch date (default: 2 weeks)
.TP
-.B \-\-cc\-trailers
-Copy all Cc\(aqd addresses into Cc: trailers, if not already present
+.B \-3\fP,\fB \-\-prep\-3way
+Prepare for a 3\-way merge (tries to ensure that all index blobs exist by making a fake commit range)
.TP
-.B \-\-no\-cover
+.B \-\-no\-cover
Do not save the cover letter (on by default when using \-o \-)
.TP
-.B \-\-no\-partial\-reroll
+.B \-\-no\-partial\-reroll
Do not reroll partial series when detected
.UNINDENT
.UNINDENT
.sp
\fIExample\fP: b4 am \fI\%20200313231252.64999\-1\-keescook@chromium.org\fP
-.SS b4 attest
+.SS b4 shazam
+.sp
+This is very similar to \fBb4 am\fP, but will also apply patches
+directly to the current git tree using \fBgit am\fP\&. Alternatively, when
+used with \fB\-H\fP, it can fetch the patch series into \fBFETCH_HEAD\fP as
+if it were a pull request, so it can be reviewed and merged. In this
+case, the cover letter is used as a template for the merge commit.
.sp
-usage: b4 attest [\-h] patchfile [patchfile ...]
+If you want to automatically invoke git\-merge, you can use \fB\-M\fP
+instead of \fB\-H\fP\&.
.INDENT 0.0
.TP
+.B usage:
+b4 shazam [\-h] [\-p USEPROJECT] [\-m LOCALMBOX] [\-C] [\-v WANTVER] [\-t] [\-S] [\-T] [\-s] [\-l] [\-P CHERRYPICK] [\-\-cc\-trailers] [\-\-no\-parent] [\-\-allow\-unicode\-control\-chars] [\-H | \-M] [\-\-guess\-lookback GUESSDAYS] [msgid]
+.TP
.B positional arguments:
-patchfile Patches to attest
+msgid Message ID to process, or pipe a raw message
+.TP
+.B options:
+.INDENT 7.0
+.TP
+.B \-h\fP,\fB \-\-help
+show this help message and exit
+.TP
+.BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT
+Use a specific project instead of default (linux\-mm, linux\-hardening, etc)
+.TP
+.BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX
+Instead of grabbing a thread from lore, process this mbox file (or \- for stdin)
+.TP
+.B \-C\fP,\fB \-\-no\-cache
+Do not use local cache
+.TP
+.BI \-v \ WANTVER\fR,\fB \ \-\-use\-version \ WANTVER
+Get a specific version of the patch/series
+.TP
+.B \-t\fP,\fB \-\-apply\-cover\-trailers
+Apply trailers sent to the cover letter to all patches
+.TP
+.B \-S\fP,\fB \-\-sloppy\-trailers
+Apply trailers without email address match checking
+.TP
+.B \-T\fP,\fB \-\-no\-add\-trailers
+Do not add or sort any trailers
+.TP
+.B \-s\fP,\fB \-\-add\-my\-sob
+Add your own signed\-off\-by to every patch
+.TP
+.B \-l\fP,\fB \-\-add\-link
+Add a Link: with message\-id lookup URL to every patch
+.TP
+.BI \-P \ CHERRYPICK\fR,\fB \ \-\-cherry\-pick \ CHERRYPICK
+Cherry\-pick a subset of patches (e.g. "\-P 1\-2,4,6\-", "\-P _" to use just the msgid specified, or "\-P \fIglobbing\fP" to match on commit subject)
+.TP
+.B \-\-cc\-trailers
+Copy all Cc\(aqd addresses into Cc: trailers
+.TP
+.B \-\-no\-parent
+Break thread at the msgid specified and ignore any parent messages
+.TP
+.B \-\-allow\-unicode\-control\-chars
+Allow unicode control characters (very rarely legitimate)
+.TP
+.B \-H\fP,\fB \-\-make\-fetch\-head
+Attempt to treat series as a pull request and fetch it into FETCH_HEAD
+.TP
+.B \-M\fP,\fB \-\-merge
+Attempt to merge series as if it were a pull request (execs git\-merge)
+.TP
+.BI \-\-guess\-lookback \ GUESSDAYS
+(use with \-H or \-M) When guessing base, go back this many days from the patch date (default: 3 weeks)
+.UNINDENT
.UNINDENT
.sp
-\fIExample\fP: b4 attest outgoing/*.patch
+\fIExample\fP: b4 shazam \-H \fI\%20200313231252.64999\-1\-keescook@chromium.org\fP
.SS b4 pr
+.sp
+This command is for working with pull requests submitted using
+\fBgit\-request\-pull\fP\&.
.INDENT 0.0
.TP
.B usage:
@@ -216,7 +333,7 @@ msgid Message ID to process, or pipe a raw message
.B optional arguments:
.INDENT 7.0
.TP
-.B \-h\fP,\fB \-\-help
+.B \-h\fP,\fB \-\-help
show this help message and exit
.TP
.BI \-g \ GITDIR\fR,\fB \ \-\-gitdir \ GITDIR
@@ -225,16 +342,16 @@ Operate on this git tree instead of current dir
.BI \-b \ BRANCH\fR,\fB \ \-\-branch \ BRANCH
Check out FETCH_HEAD into this branch after fetching
.TP
-.B \-c\fP,\fB \-\-check
+.B \-c\fP,\fB \-\-check
Check if pull request has already been applied
.TP
-.B \-e\fP,\fB \-\-explode
+.B \-e\fP,\fB \-\-explode
Convert a pull request into an mbox full of patches
.TP
.BI \-o \ OUTMBOX\fR,\fB \ \-\-output\-mbox \ OUTMBOX
Save exploded messages into this mailbox (default: msgid.mbx)
.TP
-.B \-l\fP,\fB \-\-retrieve\-links
+.B \-l\fP,\fB \-\-retrieve\-links
Attempt to retrieve any Link: URLs (use with \-e)
.TP
.BI \-f \ MAILFROM\fR,\fB \ \-\-from\-addr \ MAILFROM
@@ -247,12 +364,12 @@ Use this From: in exploded messages (use with \-e)
.INDENT 0.0
.TP
.B usage:
-b4 ty [\-h] [\-g GITDIR] [\-o OUTDIR] [\-l] [\-s SEND [SEND ...]] [\-d DISCARD [DISCARD ...]] [\-a] [\-b BRANCH] [\-\-since SINCE]
+b4 ty [\-h] [\-g GITDIR] [\-o OUTDIR] [\-l] [\-t THANK_FOR [THANK_FOR ...]] [\-d DISCARD [DISCARD ...]] [\-a] [\-b BRANCH] [\-\-since SINCE] [\-S] [\-\-dry\-run]
.TP
.B optional arguments:
.INDENT 7.0
.TP
-.B \-h\fP,\fB \-\-help
+.B \-h\fP,\fB \-\-help
show this help message and exit
.TP
.BI \-g \ GITDIR\fR,\fB \ \-\-gitdir \ GITDIR
@@ -261,32 +378,48 @@ Operate on this git tree instead of current dir
.BI \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR
Write thanks files into this dir (default=.)
.TP
-.B \-l\fP,\fB \-\-list
+.B \-l\fP,\fB \-\-list
List pull requests and patch series you have retrieved
.TP
-.BI \-s \ SEND\fR,\fB \ \-\-send \ SEND
+.BI \-t \ THANK_FOR\fR,\fB \ \-\-thank\-for \ THANK_FOR
Generate thankyous for specific entries from \-l (e.g.: 1,3\-5,7\-; or "all")
.TP
.BI \-d \ DISCARD\fR,\fB \ \-\-discard \ DISCARD
Discard specific messages from \-l (e.g.: 1,3\-5,7\-; or "all")
.TP
-.B \-a\fP,\fB \-\-auto
-Use the Auto\-Thankanator to figure out what got applied/merged
+.B \-a\fP,\fB \-\-auto
+Use the Auto\-Thankanator gun to figure out what got applied/merged
.TP
.BI \-b \ BRANCH\fR,\fB \ \-\-branch \ BRANCH
The branch to check against, instead of current
.TP
.BI \-\-since \ SINCE
The \-\-since option to use when auto\-matching patches (default=1.week)
+.TP
+.B \-S\fP,\fB \-\-send\-email
+Send email instead of writing out .thanks files
+.TP
+.B \-\-dry\-run
+Print out emails instead of sending them
.UNINDENT
.UNINDENT
.sp
-\fIExample\fP: b4 ty \-\-auto
-.SS b4 diff
+\fBNOTE:\fP
+.INDENT 0.0
+.INDENT 3.5
+To send mails directly using \-S, you should have a configured
+[sendemail] section somewhere in your applicable git configuration
+files (global or in\-tree).
+.UNINDENT
+.UNINDENT
.sp
-usage: b4 diff [\-h] [\-g GITDIR] [\-p USEPROJECT] [\-C] [\-v WANTVERS [WANTVERS ...]] [\-n] [\-o OUTDIFF] [\-c] [\-m AMBOX AMBOX] [msgid]
+\fIExample\fP: b4 ty \-aS \-\-dry\-run
+.SS b4 diff
.INDENT 0.0
.TP
+.B usage:
+b4 diff [\-h] [\-g GITDIR] [\-p USEPROJECT] [\-C] [\-v WANTVERS [WANTVERS ...]] [\-n] [\-o OUTDIFF] [\-c] [\-m AMBOX AMBOX] [msgid]
+.TP
.B positional arguments:
msgid Message ID to process, pipe a raw message, or use \-m
.UNINDENT
@@ -296,7 +429,7 @@ optional arguments:
.INDENT 3.5
.INDENT 0.0
.TP
-.B \-h\fP,\fB \-\-help
+.B \-h\fP,\fB \-\-help
show this help message and exit
.TP
.BI \-g \ GITDIR\fR,\fB \ \-\-gitdir \ GITDIR
@@ -305,7 +438,7 @@ Operate on this git tree instead of current dir
.BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT
Use a specific project instead of guessing (linux\-mm, linux\-hardening, etc)
.TP
-.B \-C\fP,\fB \-\-no\-cache
+.B \-C\fP,\fB \-\-no\-cache
Do not use local cache
.UNINDENT
.INDENT 0.0
@@ -315,13 +448,13 @@ Compare specific versions instead of latest and one before that, e.g. \-v 3 5
.UNINDENT
.INDENT 0.0
.TP
-.B \-n\fP,\fB \-\-no\-diff
+.B \-n\fP,\fB \-\-no\-diff
Do not generate a diff, just show the command to do it
.TP
.BI \-o \ OUTDIFF\fR,\fB \ \-\-output\-diff \ OUTDIFF
Save diff into this file instead of outputting to stdout
.TP
-.B \-c\fP,\fB \-\-color
+.B \-c\fP,\fB \-\-color
Force color output even when writing to file
.UNINDENT
.INDENT 0.0
@@ -334,17 +467,18 @@ Compare two mbx files prepared with "b4 am"
.sp
\fIExample\fP: b4 diff \fI\%20200526205322.23465\-1\-mic@digikod.net\fP
.SS b4 kr
-.sp
-usage: b4 kr [\-h] [\-p USEPROJECT] [\-m LOCALMBOX] [\-C] [\-\-show\-keys] [msgid]
.INDENT 0.0
.TP
+.B usage:
+b4 kr [\-h] [\-p USEPROJECT] [\-m LOCALMBOX] [\-C] [\-\-show\-keys] [msgid]
+.TP
.B positional arguments:
msgid Message ID to process, or pipe a raw message
.TP
.B optional arguments:
.INDENT 7.0
.TP
-.B \-h\fP,\fB \-\-help
+.B \-h\fP,\fB \-\-help
show this help message and exit
.TP
.BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT
@@ -353,10 +487,10 @@ Use a specific project instead of guessing (linux\-mm, linux\-hardening, etc)
.BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX
Instead of grabbing a thread from lore, process this mbox file (or \- for stdin)
.TP
-.B \-C\fP,\fB \-\-no\-cache
+.B \-C\fP,\fB \-\-no\-cache
Do not use local cache
.TP
-.B \-\-show\-keys
+.B \-\-show\-keys
Show all developer keys from the thread
.UNINDENT
.UNINDENT
@@ -393,13 +527,6 @@ Default configuration, with explanations:
# public\-inbox, python, and git
save\-maildirs = no
#
- # When processing thread trailers, sort them in this order.
- # Can use shell\-globbing and must end with ,*
- # Some sorting orders:
- #trailer\-order=link*,fixes*,cc*,reported*,suggested*,original*,co\-*,tested*,reviewed*,acked*,signed\-off*,*
- #trailer\-order = fixes*,reported*,suggested*,original*,co\-*,signed\-off*,tested*,reviewed*,acked*,cc*,link*,*
- trailer\-order = _preserve_
- #
# Attestation\-checking configuration parameters
# off: do not bother checking attestation
# check: print an attaboy when attestation is found
@@ -437,10 +564,24 @@ Default configuration, with explanations:
thanks\-pr\-template = None
# See thanks\-am\-template.example. If not set, a default template will be used.
thanks\-am\-template = None
+ # additional flags to pass to "git am" when we run "b4 shazam"
+ shazam\-am\-flags = None
+ # additional flags to pass to "git merge" when we run "b4 shazam \-M"
+ shazam\-merge\-flags = \-\-signoff
+ # Used when preparing merge messages from cover letters. See shazam\-merge\-template.example
+ shazam\-merge\-template = None
+ # Use to exclude certain mail addresses from ever being added to auto\-generated mail
+ # Separate multiple entries using comma (spaces are ignored), shell\-style globbing accepted
+ email\-exclude = *@codeaurora.org, example@example.com
.ft P
.fi
.UNINDENT
.UNINDENT
+.SH PROXYING REQUESTS
+.sp
+Commands making remote HTTP requests may be configured to use a proxy by
+setting the \fBHTTPS_PROXY\fP environment variable, as described in
+\fI\%https://docs.python\-requests.org/en/latest/user/advanced/#proxies\fP\&.
.SH SUPPORT
.sp
Please email \fI\%tools@linux.kernel.org\fP with support requests,
diff --git a/man/b4.5.rst b/man/b4.5.rst
index 997b947..6df33bd 100644
--- a/man/b4.5.rst
+++ b/man/b4.5.rst
@@ -5,15 +5,15 @@ Work with code submissions in a public-inbox archive
----------------------------------------------------
:Author: mricon@kernel.org
-:Date: 2020-11-20
+:Date: 2022-06-16
:Copyright: The Linux Foundation and contributors
:License: GPLv2+
-:Version: 0.7.0
+:Version: 0.9.0
:Manual section: 5
SYNOPSIS
--------
-b4 {mbox,am,attest,pr,ty,diff} [options]
+b4 {mbox,am,shazam,pr,diff,ty,kr} [options]
DESCRIPTION
-----------
@@ -27,74 +27,103 @@ precursor to Lore and Data in the Star Trek universe.
SUBCOMMANDS
-----------
-* *b4 mbox*: Download a thread as an mbox file
-* *b4 am*: Create an mbox file that is ready to git-am
-* *b4 pr*: Work with pull requests
-* *b4 diff*: Show range-diff style diffs between patch versions
-* *b4 ty*: Create templated replies for processed patches and pull requests
-* *b4 attest*: (EXPERIMENTAL) Add cryptographic attestation to patches
-* *b4 kr* (EXPERIMENTAL) Operate on patatt-compatible keyrings
+* *mbox*: Download a thread as an mbox file
+* *am*: Create an mbox file that is ready to git-am
+* *shazam*: Apply patch series to git repositories
+* *pr*: Work with pull requests
+* *diff*: Show range-diff style diffs between patch versions
+* *ty*: Create templated replies for processed patches and pull requests
+* *kr* (EXPERIMENTAL) Operate on patatt-compatible keyrings
OPTIONS
-------
-h, --help show this help message and exit
-d, --debug Add more debugging info to the output (default: False)
-q, --quiet Output critical information only (default: False)
+-n, --no-interactive Do not ask any interactive questions (default: False)
SUBCOMMAND OPTIONS
------------------
+
b4 mbox
~~~~~~~
+
+This command allows retrieving entire threads from a remote public-inbox
+instance. The resulting mbox file can then be opened with most MUA
+clients for actions like replying to conversations or reviewing patch
+submissions.
+
usage:
- b4 mbox [-h] [-o OUTDIR] [-p USEPROJECT] [-c] [-n WANTNAME] [-m LOCALMBOX] [msgid]
+ b4 mbox [-h] [-p USEPROJECT] [-m LOCALMBOX] [-C] [-o OUTDIR] [-c] [-n WANTNAME] [-M] [-f] [msgid]
positional arguments:
msgid Message ID to process, or pipe a raw message
-optional arguments:
+options:
-h, --help show this help message and exit
+ -p USEPROJECT, --use-project USEPROJECT
+ Use a specific project instead of default (linux-mm, linux-hardening, etc)
+ -m LOCALMBOX, --use-local-mbox LOCALMBOX
+ Instead of grabbing a thread from lore, process this mbox file (or - for stdin)
+ -C, --no-cache
+ Do not use local cache
-o OUTDIR, --outdir OUTDIR
Output into this directory (or use - to output mailbox contents to stdout)
- -p USEPROJECT, --use-project USEPROJECT
- Use a specific project instead of guessing (linux-mm, linux-hardening, etc)
-c, --check-newer-revisions
Check if newer patch revisions exist
-n WANTNAME, --mbox-name WANTNAME
- Filename to name the mbox file
- -m LOCALMBOX, --use-local-mbox LOCALMBOX
- Instead of grabbing a thread from lore, process this mbox file
- (or use - for stdin)
- -C, --no-cache Do not use local cache
- -f, --filter-dupes When adding messages to existing maildir, filter out duplicates
+ Filename to name the mbox destination
-M, --save-as-maildir
Save as maildir (avoids mbox format ambiguities)
+ -f, --filter-dupes
+ When adding messages to existing maildir, filter out duplicates
+
*Example*: b4 mbox 20200313231252.64999-1-keescook@chromium.org
b4 am
~~~~~
+
+This command allows retrieving threads from a public-inbox instance and
+preparing them for applying to a git repository using the "git am"
+command. It will automatically perform the following operations:
+
+* pick the latest submitted version of the series (it can check for
+ newer threads using ``-c`` as well)
+* check DKIM signatures and patatt attestation on all patches and code
+ review messages
+* collate all submitted code-review trailers (Reviewed-by, Acked-by,
+ etc) and put them into the commit message
+* add your own Signed-off-by trailer (with ``-s``)
+* reroll series from partial updates (e.g. someone submits a v2 of a
+ single patch instead of rerolling the entire series)
+* guess where in the tree history the patches belong, if the exact
+ commit-base is not specified (with ``-g``)
+* prepare the tree for a 3-way merge (with ``-3``)
+* cherry-pick a subset of patches from a large series (with ``-P``)
+
usage:
- b4 am [-h] [-o OUTDIR] [-p USEPROJECT] [-c] [-n WANTNAME] [-m LOCALMBOX] [-v WANTVER] [-t] [-T] [-s] [-l] [-Q] [msgid]
+ b4 am [-h] [-p USEPROJECT] [-m LOCALMBOX] [-C] [-o OUTDIR] [-c] [-n WANTNAME] [-M] [-v WANTVER] [-t] [-S] [-T] [-s] [-l] [-P CHERRYPICK] [--cc-trailers] [--no-parent] [--allow-unicode-control-chars] [-Q] [-g] [-b GUESSBRANCH [GUESSBRANCH ...]] [--guess-lookback GUESSDAYS] [-3] [--no-cover] [--no-partial-reroll] [msgid]
positional arguments:
msgid Message ID to process, or pipe a raw message
-optional arguments:
+options:
-h, --help show this help message and exit
+ -p USEPROJECT, --use-project USEPROJECT
+ Use a specific project instead of default (linux-mm, linux-hardening, etc)
+ -m LOCALMBOX, --use-local-mbox LOCALMBOX
+ Instead of grabbing a thread from lore, process this mbox file (or - for stdin)
+ -C, --no-cache
+ Do not use local cache
-o OUTDIR, --outdir OUTDIR
Output into this directory (or use - to output mailbox contents to stdout)
- -p USEPROJECT, --use-project USEPROJECT
- Use a specific project instead of guessing (linux-mm, linux-hardening, etc)
-c, --check-newer-revisions
Check if newer patch revisions exist
-n WANTNAME, --mbox-name WANTNAME
- Filename to name the mbox file
- -m LOCALMBOX, --use-local-mbox LOCALMBOX
- Instead of grabbing a thread from lore, process this mbox file
- (or use - for stdin)
+ Filename to name the mbox destination
-M, --save-as-maildir
Save as maildir (avoids mbox format ambiguities)
- -C, --no-cache Do not use local cache
-v WANTVER, --use-version WANTVER
Get a specific version of the patch/series
-t, --apply-cover-trailers
@@ -103,36 +132,92 @@ optional arguments:
Apply trailers without email address match checking
-T, --no-add-trailers
Do not add or sort any trailers
- -s, --add-my-sob Add your own signed-off-by to every patch
- -l, --add-link Add a lore.kernel.org/r/ link to every patch
- -Q, --quilt-ready Save patches in a quilt-ready folder
+ -s, --add-my-sob
+ Add your own signed-off-by to every patch
+ -l, --add-link
+ Add a Link: with message-id lookup URL to every patch
-P CHERRYPICK, --cherry-pick CHERRYPICK
- Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", "-P _" to use just the msgid specified, or "-P \*globbing\*" to match on commit subject)
+ Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", "-P _" to use just the msgid specified, or "-P *globbing*" to match on commit subject)
+ --cc-trailers
+ Copy all Cc'd addresses into Cc: trailers
+ --no-parent
+ Break thread at the msgid specified and ignore any parent messages
+ --allow-unicode-control-chars
+ Allow unicode control characters (very rarely legitimate)
+ -Q, --quilt-ready
+ Save patches in a quilt-ready folder
-g, --guess-base
Try to guess the base of the series (if not specified)
+
+ -b GUESSBRANCH [GUESSBRANCH ...], --guess-branch GUESSBRANCH [GUESSBRANCH ...]
+ When guessing base, restrict to this branch (use with -g)
+
+ --guess-lookback GUESSDAYS
+ When guessing base, go back this many days from the patch date (default: 2 weeks)
-3, --prep-3way
Prepare for a 3-way merge (tries to ensure that all index blobs exist by making a fake commit range)
- --cc-trailers
- Copy all Cc'd addresses into Cc: trailers, if not already present
--no-cover
Do not save the cover letter (on by default when using -o -)
--no-partial-reroll
Do not reroll partial series when detected
-
*Example*: b4 am 20200313231252.64999-1-keescook@chromium.org
-b4 attest
+b4 shazam
~~~~~~~~~
-usage: b4 attest [-h] patchfile [patchfile ...]
+
+This is very similar to **b4 am**, but will also apply patches
+directly to the current git tree using ``git am``. Alternatively, when
+used with ``-H``, it can fetch the patch series into ``FETCH_HEAD`` as
+if it were a pull request, so it can be reviewed and merged. In this
+case, the cover letter is used as a template for the merge commit.
+
+If you want to automatically invoke git-merge, you can use ``-M``
+instead of ``-H``.
+
+usage:
+ b4 shazam [-h] [-p USEPROJECT] [-m LOCALMBOX] [-C] [-v WANTVER] [-t] [-S] [-T] [-s] [-l] [-P CHERRYPICK] [--cc-trailers] [--no-parent] [--allow-unicode-control-chars] [-H | -M] [--guess-lookback GUESSDAYS] [msgid]
positional arguments:
- patchfile Patches to attest
+ msgid Message ID to process, or pipe a raw message
-*Example*: b4 attest outgoing/\*.patch
+options:
+ -h, --help show this help message and exit
+ -p USEPROJECT, --use-project USEPROJECT
+ Use a specific project instead of default (linux-mm, linux-hardening, etc)
+ -m LOCALMBOX, --use-local-mbox LOCALMBOX
+ Instead of grabbing a thread from lore, process this mbox file (or - for stdin)
+ -C, --no-cache Do not use local cache
+ -v WANTVER, --use-version WANTVER
+ Get a specific version of the patch/series
+ -t, --apply-cover-trailers
+ Apply trailers sent to the cover letter to all patches
+ -S, --sloppy-trailers
+ Apply trailers without email address match checking
+ -T, --no-add-trailers
+ Do not add or sort any trailers
+ -s, --add-my-sob Add your own signed-off-by to every patch
+ -l, --add-link Add a Link: with message-id lookup URL to every patch
+ -P CHERRYPICK, --cherry-pick CHERRYPICK
+ Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", "-P _" to use just the msgid specified, or "-P *globbing*" to match on commit subject)
+ --cc-trailers Copy all Cc'd addresses into Cc: trailers
+ --no-parent Break thread at the msgid specified and ignore any parent messages
+ --allow-unicode-control-chars
+ Allow unicode control characters (very rarely legitimate)
+ -H, --make-fetch-head
+ Attempt to treat series as a pull request and fetch it into FETCH_HEAD
+ -M, --merge
+ Attempt to merge series as if it were a pull request (execs git-merge)
+ --guess-lookback GUESSDAYS
+ (use with -H or -M) When guessing base, go back this many days from the patch date (default: 3 weeks)
+
+*Example*: b4 shazam -H 20200313231252.64999-1-keescook@chromium.org
b4 pr
~~~~~
+This command is for working with pull requests submitted using
+``git-request-pull``.
+
usage:
command.py pr [-h] [-g GITDIR] [-b BRANCH] [-c] [-e] [-o OUTMBOX] [msgid]
@@ -158,7 +243,7 @@ optional arguments:
b4 ty
~~~~~
usage:
- b4 ty [-h] [-g GITDIR] [-o OUTDIR] [-l] [-s SEND [SEND ...]] [-d DISCARD [DISCARD ...]] [-a] [-b BRANCH] [--since SINCE]
+ b4 ty [-h] [-g GITDIR] [-o OUTDIR] [-l] [-t THANK_FOR [THANK_FOR ...]] [-d DISCARD [DISCARD ...]] [-a] [-b BRANCH] [--since SINCE] [-S] [--dry-run]
optional arguments:
-h, --help show this help message and exit
@@ -167,19 +252,29 @@ optional arguments:
-o OUTDIR, --outdir OUTDIR
Write thanks files into this dir (default=.)
-l, --list List pull requests and patch series you have retrieved
- -s SEND, --send SEND Generate thankyous for specific entries from -l (e.g.: 1,3-5,7-; or "all")
+ -t THANK_FOR, --thank-for THANK_FOR
+ Generate thankyous for specific entries from -l (e.g.: 1,3-5,7-; or "all")
-d DISCARD, --discard DISCARD
Discard specific messages from -l (e.g.: 1,3-5,7-; or "all")
- -a, --auto Use the Auto-Thankanator to figure out what got applied/merged
+ -a, --auto Use the Auto-Thankanator gun to figure out what got applied/merged
-b BRANCH, --branch BRANCH
The branch to check against, instead of current
--since SINCE The --since option to use when auto-matching patches (default=1.week)
+ -S, --send-email Send email instead of writing out .thanks files
+ --dry-run Print out emails instead of sending them
-*Example*: b4 ty --auto
+.. note::
+
+ To send mails directly using -S, you should have a configured
+ [sendemail] section somewhere in your applicable git configuration
+ files (global or in-tree).
+
+*Example*: b4 ty -aS --dry-run
b4 diff
~~~~~~~
-usage: b4 diff [-h] [-g GITDIR] [-p USEPROJECT] [-C] [-v WANTVERS [WANTVERS ...]] [-n] [-o OUTDIFF] [-c] [-m AMBOX AMBOX] [msgid]
+usage:
+ b4 diff [-h] [-g GITDIR] [-p USEPROJECT] [-C] [-v WANTVERS [WANTVERS ...]] [-n] [-o OUTDIFF] [-c] [-m AMBOX AMBOX] [msgid]
positional arguments:
msgid Message ID to process, pipe a raw message, or use -m
@@ -211,7 +306,8 @@ optional arguments:
b4 kr
~~~~~
-usage: b4 kr [-h] [-p USEPROJECT] [-m LOCALMBOX] [-C] [--show-keys] [msgid]
+usage:
+ b4 kr [-h] [-p USEPROJECT] [-m LOCALMBOX] [-C] [--show-keys] [msgid]
positional arguments:
msgid Message ID to process, or pipe a raw message
@@ -254,13 +350,6 @@ Default configuration, with explanations::
# public-inbox, python, and git
save-maildirs = no
#
- # When processing thread trailers, sort them in this order.
- # Can use shell-globbing and must end with ,*
- # Some sorting orders:
- #trailer-order=link*,fixes*,cc*,reported*,suggested*,original*,co-*,tested*,reviewed*,acked*,signed-off*,*
- #trailer-order = fixes*,reported*,suggested*,original*,co-*,signed-off*,tested*,reviewed*,acked*,cc*,link*,*
- trailer-order = _preserve_
- #
# Attestation-checking configuration parameters
# off: do not bother checking attestation
# check: print an attaboy when attestation is found
@@ -298,7 +387,21 @@ Default configuration, with explanations::
thanks-pr-template = None
# See thanks-am-template.example. If not set, a default template will be used.
thanks-am-template = None
-
+ # additional flags to pass to "git am" when we run "b4 shazam"
+ shazam-am-flags = None
+ # additional flags to pass to "git merge" when we run "b4 shazam -M"
+ shazam-merge-flags = --signoff
+ # Used when preparing merge messages from cover letters. See shazam-merge-template.example
+ shazam-merge-template = None
+ # Use to exclude certain mail addresses from ever being added to auto-generated mail
+ # Separate multiple entries using comma (spaces are ignored), shell-style globbing accepted
+ email-exclude = *@codeaurora.org, example@example.com
+
+PROXYING REQUESTS
+-----------------
+Commands making remote HTTP requests may be configured to use a proxy by
+setting the **HTTPS_PROXY** environment variable, as described in
+https://docs.python-requests.org/en/latest/user/advanced/#proxies.
SUPPORT
-------
diff --git a/misc/default.conf b/misc/default.conf
new file mode 100644
index 0000000..a7a78d2
--- /dev/null
+++ b/misc/default.conf
@@ -0,0 +1,63 @@
+[main]
+ # This is what we describe ourselves as in messages
+ myname = B4 Web Endpoint
+ # The URL to the submission endpoint
+ myurl = http://localhost:8000/_b4_submit
+ # This must be in a format that is understood by SQLAlchemy, and you obviously
+ # don't want to use the default, which gets lost every time the app is restarted
+ dburl = sqlite:///:memory:
+ # These are the domains for which we have DKIM signing capabilities, so if we
+ # receive a submission with that domain in From, we don't have to do any
+ # in-body From substitution, Reply-To tricks, etc.
+ mydomains = kernel.org, linux.dev
+ # One of the To: or Cc: addrs must match this regex
+ # (to ensure that the message was intended to go to mailing lists)
+ mustdest = .*@(vger\.kernel\.org|lists\.linux\.dev|lists\.infradead\.org)
+ # Always bcc the address(es) listed here, separated by comma
+ # Useful during initial testing
+ #alwaysbcc = one@example.com, two@example.com
+ # If dryrun is set, the messages are printed to stdout instead of
+ # being actually sent out. Useful for testing.
+ #dryrun = false
+ # Where to write our app-specific logs. Make sure it's writable by the
+ # web process.
+ #logfile = /var/log/somewhere.log
+ # Can be "info", "debug", "critical"
+ #loglevel = info
+
+# This section matches the git's sendemail section one-for-one
+[sendemail]
+ from = Web Endpoint <devnull@kernel.org>
+ smtpserver = localhost
+ smtpserverport = 25
+ #smtpencryption =
+ #smtpuser =
+ #smtppass =
+
+# information about the public-inbox feed we'll be writing to
+# NOTE: we won't init the git repository, so make sure it's present
+[public-inbox]
+ # Path to the public-inbox git repository. If there is a hooks/post-commit,
+ # we will execute it after writing a new batch of messages to the repo.
+ repo =
+ # This is required for public-inbox to work correctly
+ listid = Web Submitted Patches <patches.feeds.kernel.org>
+
+[templates]
+verify-subject = Web endpoint verification for $${identity}
+verify-body = Dear $${name}:
+
+ Somebody, probably you, initiated a web endpoint verification routine
+ for patch submissions at: $${myurl}
+
+ If you have no idea what is going on, please ignore this message
+ Otherwise, please follow instructions provided by your tool and paste
+ the following string:
+
+ $${challenge}
+
+ Happy patching!
+
+signature = Deet-doot-dot, I am a bot!
+ https://korg.docs.kernel.org
+
diff --git a/misc/send-receive.py b/misc/send-receive.py
new file mode 100644
index 0000000..c15e12a
--- /dev/null
+++ b/misc/send-receive.py
@@ -0,0 +1,545 @@
+#!/usr/bin/env python3
+# noinspection PyUnresolvedReferences
+import falcon
+import os
+import sys
+import logging
+import logging.handlers
+import json
+import sqlalchemy as sa
+import patatt
+import smtplib
+import email
+import email.header
+import email.policy
+import re
+import ezpi
+import copy
+
+from configparser import ConfigParser, ExtendedInterpolation
+from string import Template
+from email import utils
+from typing import Tuple, Union
+
+from email import charset
+charset.add_charset('utf-8', None)
+emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length=None)
+
+DB_VERSION = 1
+
+logger = logging.getLogger('b4-send-receive')
+logger.setLevel(logging.DEBUG)
+
+
+# noinspection PyBroadException, PyMethodMayBeStatic
+class SendReceiveListener(object):
+
+ def __init__(self, _engine, _config) -> None:
+ self._engine = _engine
+ self._config = _config
+ # You shouldn't use this in production
+ if self._engine.driver == 'pysqlite':
+ self._init_sa_db()
+ logfile = _config['main'].get('logfile')
+ loglevel = _config['main'].get('loglevel', 'info')
+ if logfile:
+ self._init_logger(logfile, loglevel)
+
+ def _init_logger(self, logfile: str, loglevel: str) -> None:
+ global logger
+ lch = logging.handlers.WatchedFileHandler(os.path.expanduser(logfile))
+ lfmt = logging.Formatter('[%(process)d] %(asctime)s - %(levelname)s - %(message)s')
+ lch.setFormatter(lfmt)
+ if loglevel == 'critical':
+ lch.setLevel(logging.CRITICAL)
+ elif loglevel == 'debug':
+ lch.setLevel(logging.DEBUG)
+ else:
+ lch.setLevel(logging.INFO)
+ logger.addHandler(lch)
+
+ def _init_sa_db(self) -> None:
+ logger.info('Setting up SQLite database')
+ conn = self._engine.connect()
+ md = sa.MetaData()
+ meta = sa.Table('meta', md,
+ sa.Column('version', sa.Integer())
+ )
+ auth = sa.Table('auth', md,
+ sa.Column('auth_id', sa.Integer(), primary_key=True),
+ sa.Column('created', sa.DateTime(), nullable=False, server_default=sa.sql.func.now()),
+ sa.Column('identity', sa.Text(), nullable=False),
+ sa.Column('selector', sa.Text(), nullable=False),
+ sa.Column('pubkey', sa.Text(), nullable=False),
+ sa.Column('challenge', sa.Text(), nullable=True),
+ sa.Column('verified', sa.Integer(), nullable=False),
+ )
+ sa.Index('idx_identity_selector', auth.c.identity, auth.c.selector, unique=True)
+ md.create_all(self._engine)
+ q = sa.insert(meta).values(version=DB_VERSION)
+ conn.execute(q)
+ conn.close()
+
+ def on_get(self, req, resp): # noqa
+ resp.status = falcon.HTTP_200
+ resp.content_type = falcon.MEDIA_TEXT
+ resp.text = "We don't serve GETs here\n"
+
+ def send_error(self, resp, message: str) -> None:
+ resp.status = falcon.HTTP_500
+ logger.critical('Returning error: %s', message)
+ resp.text = json.dumps({'result': 'error', 'message': message})
+
+ def send_success(self, resp, message: str) -> None:
+ resp.status = falcon.HTTP_200
+ logger.debug('Returning success: %s', message)
+ resp.text = json.dumps({'result': 'success', 'message': message})
+
+ def get_smtp(self) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, None], Tuple[str, str]]:
+ sconfig = self._config['sendemail']
+ server = sconfig.get('smtpserver', 'localhost')
+ port = sconfig.get('smtpserverport', 0)
+ encryption = sconfig.get('smtpencryption')
+
+ logger.debug('Connecting to %s:%s', server, port)
+ # We only authenticate if we have encryption
+ if encryption:
+ if encryption in ('tls', 'starttls'):
+ # We do startssl
+ smtp = smtplib.SMTP(server, port)
+ # Introduce ourselves
+ smtp.ehlo()
+ # Start encryption
+ smtp.starttls()
+ # Introduce ourselves again to get new criteria
+ smtp.ehlo()
+ elif encryption in ('ssl', 'smtps'):
+ # We do TLS from the get-go
+ smtp = smtplib.SMTP_SSL(server, port)
+ else:
+ raise smtplib.SMTPException('Unclear what to do with smtpencryption=%s' % encryption)
+
+ # If we got to this point, we should do authentication.
+ auser = sconfig.get('smtpuser')
+ apass = sconfig.get('smtppass')
+ if auser and apass:
+ # Let any exceptions bubble up
+ smtp.login(auser, apass)
+ else:
+ # We assume you know what you're doing if you don't need encryption
+ smtp = smtplib.SMTP(server, port)
+
+ frompair = utils.getaddresses([sconfig.get('from')])[0]
+ return smtp, frompair
+
+ def auth_new(self, jdata, resp) -> None:
+ # Is it already authorized?
+ conn = self._engine.connect()
+ md = sa.MetaData()
+ identity = jdata.get('identity')
+ selector = jdata.get('selector')
+ logger.info('New authentication request for %s/%s', identity, selector)
+ pubkey = jdata.get('pubkey')
+ t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine)
+ q = sa.select([t_auth.c.auth_id]).where(t_auth.c.identity == identity, t_auth.c.selector == selector,
+ t_auth.c.verified == 1)
+ rp = conn.execute(q)
+ if len(rp.fetchall()):
+ self.send_error(resp, message='i=%s;s=%s is already authorized' % (identity, selector))
+ return
+ # delete any existing challenges for this and create a new one
+ q = sa.delete(t_auth).where(t_auth.c.identity == identity, t_auth.c.selector == selector,
+ t_auth.c.verified == 0)
+ conn.execute(q)
+ # create new challenge
+ import uuid
+ cstr = str(uuid.uuid4())
+ q = sa.insert(t_auth).values(identity=identity, selector=selector, pubkey=pubkey, challenge=cstr,
+ verified=0)
+ conn.execute(q)
+ logger.info('Created new challenge for %s/%s: %s', identity, selector, cstr)
+ conn.close()
+ smtp, frompair = self.get_smtp()
+ cmsg = email.message.EmailMessage()
+ fromname, fromaddr = frompair
+ if len(fromname):
+ cmsg.add_header('From', f'{fromname} <{fromaddr}>')
+ else:
+ cmsg.add_header('From', fromaddr)
+ tpt_subject = self._config['templates']['verify-subject'].strip()
+ tpt_body = self._config['templates']['verify-body'].strip()
+ signature = self._config['templates']['signature'].strip()
+ subject = Template(tpt_subject).safe_substitute({'identity': jdata.get('identity')})
+ cmsg.add_header('Subject', subject)
+ name = jdata.get('name', 'Anonymous Llama')
+ cmsg.add_header('To', f'{name} <{identity}>')
+ cmsg.add_header('Message-Id', utils.make_msgid('b4-verify'))
+ vals = {
+ 'name': name,
+ 'myurl': self._config['main'].get('myurl'),
+ 'challenge': cstr,
+ }
+ body = Template(tpt_body).safe_substitute(vals)
+ body += '\n-- \n'
+ body += Template(signature).safe_substitute(vals)
+ body += '\n'
+ cmsg.set_payload(body, charset='utf-8')
+ bdata = cmsg.as_bytes(policy=emlpolicy)
+ destaddrs = [identity]
+ alwaysbcc = self._config['main'].get('alwayscc')
+ if alwaysbcc:
+ destaddrs += [x[1] for x in utils.getaddresses(alwaysbcc)]
+ logger.info('Sending challenge to %s', identity)
+ smtp.sendmail(fromaddr, [identity], bdata)
+ smtp.close()
+ self.send_success(resp, message=f'Challenge generated and sent to {identity}')
+
+ def validate_message(self, conn, t_auth, bdata, verified=1) -> Tuple[str, str, int]:
+ # Returns auth_id of the matching record
+ pm = patatt.PatattMessage(bdata)
+ if not pm.signed:
+ raise patatt.ValidationError('Message is not signed')
+
+ auth_id = identity = selector = pubkey = None
+ for ds in pm.get_sigs():
+ selector = 'default'
+ identity = ''
+ i = ds.get_field('i')
+ if i:
+ identity = i.decode()
+ s = ds.get_field('s')
+ if s:
+ selector = s.decode()
+ logger.debug('i=%s; s=%s', identity, selector)
+ q = sa.select([t_auth.c.auth_id, t_auth.c.pubkey]).where(t_auth.c.identity == identity,
+ t_auth.c.selector == selector,
+ t_auth.c.verified == verified)
+ rp = conn.execute(q)
+ res = rp.fetchall()
+ if res:
+ auth_id, pubkey = res[0]
+ break
+
+ if not auth_id:
+ logger.debug('Did not find a matching identity!')
+ raise patatt.NoKeyError('No match for this identity')
+
+ logger.debug('Found matching %s/%s with auth_id=%s', identity, selector, auth_id)
+ pm.validate(identity, pubkey.encode())
+
+ return identity, selector, auth_id
+
+ def auth_verify(self, jdata, resp) -> None:
+ msg = jdata.get('msg')
+ if msg.find('\nverify:') < 0:
+ self.send_error(resp, message='Invalid verification message')
+ return
+ conn = self._engine.connect()
+ md = sa.MetaData()
+ t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine)
+ bdata = msg.encode()
+ try:
+ identity, selector, auth_id = self.validate_message(conn, t_auth, bdata, verified=0)
+ except Exception as ex:
+ self.send_error(resp, message='Signature validation failed: %s' % ex)
+ return
+ logger.debug('Message validation passed for %s/%s with auth_id=%s', identity, selector, auth_id)
+
+ # Now compare the challenge to what we received
+ q = sa.select([t_auth.c.challenge]).where(t_auth.c.auth_id == auth_id)
+ rp = conn.execute(q)
+ res = rp.fetchall()
+ challenge = res[0][0]
+ if msg.find(f'\nverify:{challenge}') < 0:
+ self.send_error(resp, message='Challenge verification for %s/%s did not match' % (identity, selector))
+ return
+ logger.info('Successfully verified challenge for %s/%s with auth_id=%s', identity, selector, auth_id)
+ q = sa.update(t_auth).where(t_auth.c.auth_id == auth_id).values(challenge=None, verified=1)
+ conn.execute(q)
+ conn.close()
+ self.send_success(resp, message='Challenge verified for %s/%s' % (identity, selector))
+
+ def auth_delete(self, jdata, resp) -> None:
+ msg = jdata.get('msg')
+ if msg.find('\nauth-delete') < 0:
+ self.send_error(resp, message='Invalid key delete message')
+ return
+ conn = self._engine.connect()
+ md = sa.MetaData()
+ t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine)
+ bdata = msg.encode()
+ try:
+ identity, selector, auth_id = self.validate_message(conn, t_auth, bdata)
+ except Exception as ex:
+ self.send_error(resp, message='Signature validation failed: %s' % ex)
+ return
+
+ logger.info('Deleting record for %s/%s with auth_id=%s', identity, selector, auth_id)
+ q = sa.delete(t_auth).where(t_auth.c.auth_id == auth_id)
+ conn.execute(q)
+ conn.close()
+ self.send_success(resp, message='Record deleted for %s/%s' % (identity, selector))
+
+ def clean_header(self, hdrval: str) -> str:
+ if hdrval is None:
+ return ''
+
+ decoded = ''
+ for hstr, hcs in email.header.decode_header(hdrval):
+ if hcs is None:
+ hcs = 'utf-8'
+ try:
+ decoded += hstr.decode(hcs, errors='replace')
+ except LookupError:
+ # Try as utf-u
+ decoded += hstr.decode('utf-8', errors='replace')
+ except (UnicodeDecodeError, AttributeError):
+ decoded += hstr
+ new_hdrval = re.sub(r'\n?\s+', ' ', decoded)
+ return new_hdrval.strip()
+
+ def receive(self, jdata, resp) -> None:
+ servicename = self._config['main'].get('myname')
+ if not servicename:
+ servicename = 'Web Endpoint'
+ umsgs = jdata.get('messages')
+ if not umsgs:
+ self.send_error(resp, message='Missing the messages array')
+ return
+ logger.debug('Received a request for %s messages', len(umsgs))
+
+ diffre = re.compile(r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I)
+ diffstatre = re.compile(r'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.M | re.I)
+
+ msgs = list()
+ conn = self._engine.connect()
+ md = sa.MetaData()
+ t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine)
+ mustdest = self._config['main'].get('mustdest')
+ # First, validate all messages
+ seenid = identity = selector = None
+ for umsg in umsgs:
+ try:
+ identity, selector, auth_id = self.validate_message(conn, t_auth, umsg.encode())
+ except patatt.NoKeyError as ex: # noqa
+ self.send_error(resp, message='No matching record found, maybe you need to auth-verify first?')
+ return
+ except Exception as ex:
+ self.send_error(resp, message='Signature validation failed: %s' % ex)
+ return
+
+ # Make sure only a single auth_id is used within a receive session
+ if seenid is None:
+ seenid = auth_id
+ elif seenid != auth_id:
+ self.send_error(resp, message='We only support a single signing identity across patch series.')
+ return
+
+ msg = email.message_from_string(umsg)
+ logger.debug('Checking sanity on message: %s', msg.get('Subject'))
+ # Some quick sanity checking:
+ # - Subject has to start with [PATCH
+ # - Content-type may ONLY be text/plain
+ # - Has to include a diff or a diffstat
+ passes = True
+ if not msg.get('Subject', '').startswith('[PATCH '):
+ passes = False
+ if passes:
+ cte = msg.get_content_type()
+ if cte.lower() != 'text/plain':
+ passes = False
+ if passes:
+ payload = msg.get_payload()
+ if not (diffre.search(payload) or diffstatre.search(payload)):
+ passes = False
+
+ if not passes:
+ self.send_error(resp, message='This service only accepts patches')
+ return
+
+ # Make sure that From, Date, Subject, and Message-Id headers exist
+ if not msg.get('From') or not msg.get('Date') or not msg.get('Subject') or not msg.get('Message-Id'):
+ self.send_error(resp, message='Message is missing some required headers.')
+ return
+
+ # Check that To/Cc have a mailing list we recognize
+ alldests = utils.getaddresses([str(x) for x in msg.get_all('to', [])])
+ alldests += utils.getaddresses([str(x) for x in msg.get_all('cc', [])])
+ destaddrs = {x[1] for x in alldests}
+ if mustdest:
+ matched = False
+ for destaddr in destaddrs:
+ if re.search(mustdest, destaddr, flags=re.I):
+ matched = True
+ break
+ if not matched:
+ self.send_error(resp, message='Destinations must include a mailing list we recognize.')
+ return
+ msg.add_header('X-Endpoint-Received', f'by {servicename} for {identity}/{selector} with auth_id={auth_id}')
+ msgs.append((msg, destaddrs))
+
+ conn.close()
+ # All signatures verified. Prepare messages for sending.
+ cfgdomains = self._config['main'].get('mydomains')
+ if cfgdomains is not None:
+ mydomains = [x.strip() for x in cfgdomains.split(',')]
+ else:
+ mydomains = list()
+
+ smtp, frompair = self.get_smtp()
+ bccaddrs = set()
+ _bcc = self._config['main'].get('alwaysbcc')
+ if _bcc:
+ bccaddrs.update([x[1] for x in utils.getaddresses([_bcc])])
+
+ repo = listid = None
+ if 'public-inbox' in self._config and self._config['public-inbox'].get('repo'):
+ repo = self._config['public-inbox'].get('repo')
+ listid = self._config['public-inbox'].get('listid')
+ if not os.path.isdir(repo):
+ repo = None
+
+ logger.info('Sending %s messages for %s/%s', len(msgs), identity, selector)
+ for msg, destaddrs in msgs:
+ subject = self.clean_header(msg.get('Subject'))
+ if repo:
+ pmsg = copy.deepcopy(msg)
+ if pmsg.get('List-Id'):
+ pmsg.replace_header('List-Id', listid)
+ else:
+ pmsg.add_header('List-Id', listid)
+ ezpi.add_rfc822(repo, pmsg)
+ logger.debug('Wrote %s to public-inbox at %s', subject, repo)
+
+ origfrom = self.clean_header(msg.get('From'))
+ origpair = utils.getaddresses([origfrom])[0]
+ origaddr = origpair[1]
+ # Does it match one of our domains
+ mydomain = False
+ for _domain in mydomains:
+ if origaddr.endswith(f'@{_domain}'):
+ mydomain = True
+ break
+ if mydomain:
+ logger.debug('%s matches mydomain, no substitution required', origaddr)
+ fromaddr = origaddr
+ else:
+ logger.debug('%s does not match mydomain, substitution required', origaddr)
+ fromaddr = frompair[1]
+ # We can't just send this as-is due to DMARC policies. Therefore, we set
+ # Reply-To and X-Original-From.
+ origname = origpair[0]
+ if not origname:
+ origname = origpair[1]
+ msg.replace_header('From', f'{origname} via {servicename} <{fromaddr}>')
+
+ if msg.get('X-Original-From'):
+ msg.replace_header('X-Original-From', origfrom)
+ else:
+ msg.add_header('X-Original-From', origfrom)
+ if msg.get('Reply-To'):
+ msg.replace_header('Reply-To', f'<{origpair[1]}>')
+ else:
+ msg.add_header('Reply-To', f'<{origpair[1]}>')
+
+ body = msg.get_payload()
+ # Parse it as a message and see if we get a From: header
+ cmsg = email.message_from_string(body)
+ if cmsg.get('From') is None:
+ cmsg.add_header('From', origfrom)
+ msg.set_payload(cmsg.as_string(policy=emlpolicy, maxheaderlen=0), charset='utf-8')
+
+ if bccaddrs:
+ destaddrs.update(bccaddrs)
+
+ bdata = msg.as_string(policy=emlpolicy).encode()
+
+ if not self._config['main'].getboolean('dryrun'):
+ smtp.sendmail(fromaddr, list(destaddrs), bdata)
+ logger.info('Sent: %s', subject)
+ else:
+ logger.info('---DRYRUN MSG START---')
+ logger.info(msg)
+ logger.info('---DRYRUN MSG END---')
+
+ smtp.close()
+ if repo:
+ # run it once after writing all messages
+ logger.debug('Running public-inbox repo hook (if present)')
+ ezpi.run_hook(repo)
+ logger.info('Sent %s messages for %s/%s', len(msgs), identity, selector)
+ self.send_success(resp, message=f'Sent {len(msgs)} messages for {identity}/{selector}')
+
+ def on_post(self, req, resp):
+ if not req.content_length:
+ resp.status = falcon.HTTP_500
+ resp.content_type = falcon.MEDIA_TEXT
+ resp.text = 'Payload required\n'
+ return
+ raw = req.bounded_stream.read()
+ try:
+ jdata = json.loads(raw)
+ except:
+ resp.status = falcon.HTTP_500
+ resp.content_type = falcon.MEDIA_TEXT
+ resp.text = 'Failed to parse the request\n'
+ return
+ action = jdata.get('action')
+ if not action:
+ logger.critical('Action not set from %s', req.remote_addr)
+
+ logger.info('Action: %s; from: %s', action, req.remote_addr)
+ if action == 'auth-new':
+ self.auth_new(jdata, resp)
+ return
+ if action == 'auth-verify':
+ self.auth_verify(jdata, resp)
+ return
+ if action == 'auth-delete':
+ self.auth_delete(jdata, resp)
+ return
+ if action == 'receive':
+ self.receive(jdata, resp)
+ return
+
+ resp.status = falcon.HTTP_500
+ resp.content_type = falcon.MEDIA_TEXT
+ resp.text = 'Unknown action: %s\n' % action
+
+
+parser = ConfigParser(interpolation=ExtendedInterpolation())
+cfgfile = os.getenv('CONFIG')
+if not cfgfile or not os.path.exists(cfgfile):
+ sys.stderr.write('CONFIG env var is not set or is not valid')
+ sys.exit(1)
+
+parser.read(cfgfile)
+
+gpgbin = parser['main'].get('gpgbin')
+if gpgbin:
+ patatt.GPGBIN = gpgbin
+
+dburl = parser['main'].get('dburl')
+# By default, recycle db connections after 5 min
+db_pool_recycle = parser['main'].getint('dbpoolrecycle', 300)
+engine = sa.create_engine(dburl, pool_recycle=db_pool_recycle)
+srl = SendReceiveListener(engine, parser)
+app = falcon.App()
+mp = os.getenv('MOUNTPOINT', '/_b4_submit')
+app.add_route(mp, srl)
+
+
+if __name__ == '__main__':
+ from wsgiref.simple_server import make_server
+ logger.setLevel(logging.DEBUG)
+ ch = logging.StreamHandler()
+ formatter = logging.Formatter('%(message)s')
+ ch.setFormatter(formatter)
+ ch.setLevel(logging.DEBUG)
+ logger.addHandler(ch)
+
+ with make_server('', 8000, app) as httpd:
+ logger.info('Serving on port 8000...')
+
+ # Serve until process is killed
+ httpd.serve_forever()
diff --git a/patatt b/patatt
-Subproject 89006ede71f146004240978621dda9a6753992d
+Subproject 8a3da6965836d46509a0f5eab9808cdb9542dee
diff --git a/requirements.txt b/requirements.txt
index 6540b12..cd5c713 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
-requests~=2.25.0
+requests>=2.24,<3.0
# These are optional, needed for attestation features
-dnspython~=2.1.0
-dkimpy~=1.0.5
-patatt>=0.4,<2.0
+dnspython>=2.1,<3.0
+dkimpy>=1.0,<2.0
+patatt>=0.6,<2.0
+git-filter-repo>=2.30,<3.0
diff --git a/setup.py b/setup.py
index 23e75a3..458b135 100644
--- a/setup.py
+++ b/setup.py
@@ -41,12 +41,12 @@ setup(
data_files = [('share/man/man5', ['man/b4.5'])],
keywords=['git', 'lore.kernel.org', 'patches'],
install_requires=[
- 'requests~=2.24',
- 'dkimpy~=1.0',
- 'dnspython~=2.0',
- 'patatt>=0.4,<2.0',
+ 'requests>=2.24,<3.0',
+ 'dnspython>=2.0,<3.0',
+ 'dkimpy>=1.0,<2.0',
+ 'patatt>=0.5,<2.0',
],
- python_requires='>=3.6',
+ python_requires='>=3.8',
entry_points={
'console_scripts': [
'b4=b4.command:cmd'
diff --git a/shazam-merge-template.example b/shazam-merge-template.example
new file mode 100644
index 0000000..e4d49d2
--- /dev/null
+++ b/shazam-merge-template.example
@@ -0,0 +1,19 @@
+# Lines starting with '#' will be removed before invoking git-merge
+# This is the first line (title) of the merge
+# ${seriestitle}: will be a cleaned up subject of the cover
+# letter or the first patch in the series.
+# ${patch_or_series}: will say "patch" if a single patch or
+# "patch series" if more than one
+Merge ${patch_or_series} "${seriestitle}"
+
+${authorname} <${authoremail}> says:
+
+# This will be the entirety of the cover letter minus anything
+# below the "-- \n" signature line. You will almost certainly
+# want to edit it down to only include the relevant info.
+${covermessage}
+
+# This will contain a lore link to the patches in question
+Link: ${midurl}
+# git-merge will append any additional information here, depending
+# on the flags you used to invoke it (e.g. --log, --signoff, etc)
diff --git a/tests/samples/trailers-followup-custody-ref-ordered.txt b/tests/samples/trailers-followup-custody-ref-ordered.txt
new file mode 100644
index 0000000..383befb
--- /dev/null
+++ b/tests/samples/trailers-followup-custody-ref-ordered.txt
@@ -0,0 +1,41 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Link: https://msgid.link/some@msgid.here
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>
+Cc: Dev Eloper2 <dev-eloper2@example.com>
+Cc: Some List <list-1@lists.example.com>
+Fixes: abcdef01234567890
+Link: https://lore.kernel.org/some@msgid.here # bug discussion
+Suggested-by: Friendly Suggester <suggested-by@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-custody-ref-unordered.txt b/tests/samples/trailers-followup-custody-ref-unordered.txt
new file mode 100644
index 0000000..4b238da
--- /dev/null
+++ b/tests/samples/trailers-followup-custody-ref-unordered.txt
@@ -0,0 +1,41 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Link: https://msgid.link/some@msgid.here
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Suggested-by: Friendly Suggester <suggested-by@example.com>
+Fixes: abcdef01234567890
+Link: https://lore.kernel.org/some@msgid.here # bug discussion
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>
+Cc: Dev Eloper2 <dev-eloper2@example.com>
+Cc: Some List <list-1@lists.example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-custody.mbox b/tests/samples/trailers-followup-custody.mbox
new file mode 100644
index 0000000..0c4e4b8
--- /dev/null
+++ b/tests/samples/trailers-followup-custody.mbox
@@ -0,0 +1,65 @@
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH] Simple test
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+
+Follow-up trailer collating test.
+
+Link: https://msgid.link/some@msgid.here
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Suggested-by: Friendly Suggester <suggested-by@example.com>
+Fixes: abcdef01234567890
+Link: https://lore.kernel.org/some@msgid.here # bug discussion
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer1 <followup-reviewer1@example.com>
+Subject: Re: [PATCH] Simple test
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-1@example.com>
+In-Reply-To: <orig-message@example.com>
+References: <orig-message@example.com>
+
+> This is a simple trailer parsing test.
+
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+
+--
+My sig
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer2 <followup-reviewer2@example.com>
+Subject: Re: [PATCH] Simple test
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-2@example.com>
+In-Reply-To: <fwup-message-1@example.com>
+References: <orig-message@example.com> <fwup-message-1@example.com>
+
+>> This is a simple trailer parsing test.
+>
+> Reviewed-by: Followup Reviewer1 <reviewer1@example.com>
+
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+
+--
+My sig
diff --git a/tests/samples/trailers-followup-partial-reroll-ref-defaults.txt b/tests/samples/trailers-followup-partial-reroll-ref-defaults.txt
new file mode 100644
index 0000000..7fbaa03
--- /dev/null
+++ b/tests/samples/trailers-followup-partial-reroll-ref-defaults.txt
@@ -0,0 +1,73 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH v3 1/2] Simple test 1
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-1-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test patch 1.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH v3 2/2] Simple test 2
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-v3-2-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+In-Reply-To: <patch-2-message@example.com>
+References: <cover-message@example.com> <patch-2-message@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test patch 2.
+Partial reroll test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/bogus.py b/b4/bogus.py
+index 12345678..23456789 100644
+--- a/b4/bogus.py
+--- b/b4/bogus.py
+@@@ -1,1 +1,1 @@ def bogus():
+
+
+-bogus1
++bogus2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-partial-reroll.mbox b/tests/samples/trailers-followup-partial-reroll.mbox
new file mode 100644
index 0000000..0a5644f
--- /dev/null
+++ b/tests/samples/trailers-followup-partial-reroll.mbox
@@ -0,0 +1,147 @@
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH v2 0/2] Simple cover
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <cover-message@example.com>
+
+This is a cover letter. It has a diffstat.
+
+---
+b4/junk.py | 1 -
+b4/bupkes.py | 1 -
+2 files changed, 2 insertions(+), 2 deletions(-)
+
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH v2 1/2] Simple test 1
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-1-message@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+
+Follow-up trailer collating test patch 1.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH v2 2/2] Simple test 2
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-2-message@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+
+Follow-up trailer collating test patch 2.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+---
+
+diff --git a/b4/bupkes.py b/b4/bupkes.py
+index 12345678..23456789 100644
+--- a/b4/bupkes.py
+--- b/b4/bupkes.py
+@@@ -1,1 +1,1 @@ def bupkes():
+
+
+-bupkes1
++bupkes2
+
+
+--
+2.wong.fu
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer1 <followup-reviewer1@example.com>
+Subject: Re: [PATCH v2 2/2] Simple test 2
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-1@example.com>
+In-Reply-To: <patch-2-message@example.com>
+References: <patch-2-message@example.com> <cover-message@example.com>
+
+> This is a simple trailer parsing test.
+
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+
+--
+My sig
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer1 <followup-reviewer1@example.com>
+Subject: Re: [PATCH v2 0/2] Simple cover
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-2@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+
+> This is a simple trailer parsing test.
+
+Reviewed-by: Coverletter Reviewer1 <followup-reviewer1@example.com>
+
+--
+My sig
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH v3 2/2] Simple test 2
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-v3-2-message@example.com>
+In-Reply-To: <patch-2-message@example.com>
+References: <cover-message@example.com> <patch-2-message@example.com>
+
+Follow-up trailer collating test patch 2.
+Partial reroll test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+---
+
+diff --git a/b4/bogus.py b/b4/bogus.py
+index 12345678..23456789 100644
+--- a/b4/bogus.py
+--- b/b4/bogus.py
+@@@ -1,1 +1,1 @@ def bogus():
+
+
+-bogus1
++bogus2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single-ref-addlink.txt b/tests/samples/trailers-followup-single-ref-addlink.txt
new file mode 100644
index 0000000..024c842
--- /dev/null
+++ b/tests/samples/trailers-followup-single-ref-addlink.txt
@@ -0,0 +1,37 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+Link: https://lore.kernel.org/r/orig-message@example.com
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single-ref-addmysob.txt b/tests/samples/trailers-followup-single-ref-addmysob.txt
new file mode 100644
index 0000000..9da6c0b
--- /dev/null
+++ b/tests/samples/trailers-followup-single-ref-addmysob.txt
@@ -0,0 +1,36 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single-ref-copyccs.txt b/tests/samples/trailers-followup-single-ref-copyccs.txt
new file mode 100644
index 0000000..3623462
--- /dev/null
+++ b/tests/samples/trailers-followup-single-ref-copyccs.txt
@@ -0,0 +1,39 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>
+Cc: Dev Eloper2 <dev-eloper2@example.com>
+Cc: Some List <list-1@lists.example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single-ref-defaults.txt b/tests/samples/trailers-followup-single-ref-defaults.txt
new file mode 100644
index 0000000..3750c06
--- /dev/null
+++ b/tests/samples/trailers-followup-single-ref-defaults.txt
@@ -0,0 +1,35 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single-ref-noadd.txt b/tests/samples/trailers-followup-single-ref-noadd.txt
new file mode 100644
index 0000000..80a1011
--- /dev/null
+++ b/tests/samples/trailers-followup-single-ref-noadd.txt
@@ -0,0 +1,33 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single-ref-ordered.txt b/tests/samples/trailers-followup-single-ref-ordered.txt
new file mode 100644
index 0000000..2084c36
--- /dev/null
+++ b/tests/samples/trailers-followup-single-ref-ordered.txt
@@ -0,0 +1,39 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>
+Cc: Dev Eloper2 <dev-eloper2@example.com>
+Cc: Some List <list-1@lists.example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single-ref-sloppy.txt b/tests/samples/trailers-followup-single-ref-sloppy.txt
new file mode 100644
index 0000000..9b6a49d
--- /dev/null
+++ b/tests/samples/trailers-followup-single-ref-sloppy.txt
@@ -0,0 +1,37 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH] Simple test
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+Reviewed-by: Mismatched Reviewer1 <mismatched-reviewer1@example.net>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-single.mbox b/tests/samples/trailers-followup-single.mbox
new file mode 100644
index 0000000..b12f6ba
--- /dev/null
+++ b/tests/samples/trailers-followup-single.mbox
@@ -0,0 +1,78 @@
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH] Simple test
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <orig-message@example.com>
+
+Follow-up trailer collating test.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer1 <followup-reviewer1@example.com>
+Subject: Re: [PATCH] Simple test
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-1@example.com>
+In-Reply-To: <orig-message@example.com>
+References: <orig-message@example.com>
+
+> This is a simple trailer parsing test.
+
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+
+--
+My sig
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer2 <followup-reviewer2@example.com>
+Subject: Re: [PATCH] Simple test
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-2@example.com>
+In-Reply-To: <fwup-message-1@example.com>
+References: <orig-message@example.com> <fwup-message-1@example.com>
+
+>> This is a simple trailer parsing test.
+>
+> Reviewed-by: Followup Reviewer1 <reviewer1@example.com>
+
+Tested-by: Followup Reviewer2 <followup-reviewer2@example.com>
+
+--
+My sig
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Mismatched Reviewer <mismatched-reviewer1@example.com>
+Subject: Re: [PATCH] Simple test
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-3@example.com>
+In-Reply-To: <orig-message@example.com>
+References: <orig-message@example.com>
+
+> This is a simple trailer parsing test.
+
+Reviewed-by: Mismatched Reviewer1 <mismatched-reviewer1@example.net>
+
+--
+My sig
+
diff --git a/tests/samples/trailers-followup-with-cover-ref-covertrailers.txt b/tests/samples/trailers-followup-with-cover-ref-covertrailers.txt
new file mode 100644
index 0000000..8a503ea
--- /dev/null
+++ b/tests/samples/trailers-followup-with-cover-ref-covertrailers.txt
@@ -0,0 +1,75 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH v2 1/2] Simple test 1
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-1-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test patch 1.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Coverletter Reviewer1 <followup-reviewer1@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH v2 2/2] Simple test 2
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-2-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test patch 2.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Reviewed-by: Coverletter Reviewer1 <followup-reviewer1@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/bupkes.py b/b4/bupkes.py
+index 12345678..23456789 100644
+--- a/b4/bupkes.py
+--- b/b4/bupkes.py
+@@@ -1,1 +1,1 @@ def bupkes():
+
+
+-bupkes1
++bupkes2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-with-cover-ref-defaults.txt b/tests/samples/trailers-followup-with-cover-ref-defaults.txt
new file mode 100644
index 0000000..cbd1eb8
--- /dev/null
+++ b/tests/samples/trailers-followup-with-cover-ref-defaults.txt
@@ -0,0 +1,73 @@
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH v2 1/2] Simple test 1
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-1-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test patch 1.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
+From git@z Thu Jan 1 00:00:00 1970
+Subject: [PATCH v2 2/2] Simple test 2
+From: Test Test <test@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-2-message@example.com>
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>, Dev Eloper2 <dev-eloper2@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+MIME-Version: 1.0
+Content-Type: text/plain; charset="utf-8"
+Content-Transfer-Encoding: 7bit
+
+Follow-up trailer collating test patch 2.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+Signed-off-by: Test Override <test-override@example.com>
+---
+
+diff --git a/b4/bupkes.py b/b4/bupkes.py
+index 12345678..23456789 100644
+--- a/b4/bupkes.py
+--- b/b4/bupkes.py
+@@@ -1,1 +1,1 @@ def bupkes():
+
+
+-bupkes1
++bupkes2
+
+
+--
+2.wong.fu
+
diff --git a/tests/samples/trailers-followup-with-cover.mbox b/tests/samples/trailers-followup-with-cover.mbox
new file mode 100644
index 0000000..52c14c4
--- /dev/null
+++ b/tests/samples/trailers-followup-with-cover.mbox
@@ -0,0 +1,113 @@
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH v2 0/2] Simple cover
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <cover-message@example.com>
+
+This is a cover letter. It has a diffstat.
+
+---
+b4/junk.py | 1 -
+b4/bupkes.py | 1 -
+2 files changed, 2 insertions(+), 2 deletions(-)
+
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH v2 1/2] Simple test 1
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-1-message@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+
+Follow-up trailer collating test patch 1.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Test Test <test@example.com>
+Subject: [PATCH v2 2/2] Simple test 2
+To: Some List <list-1@lists.example.com>
+Cc: Dev Eloper1 <dev-eloper1@example.com>,
+ Dev Eloper2 <dev-eloper2@example.com>
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <patch-2-message@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+
+Follow-up trailer collating test patch 2.
+
+Fixes: abcdef01234567890
+Reviewed-by: Original Reviewer <original-reviewer@example.com>
+Link: https://msgid.link/some@msgid.here
+Signed-off-by: Original Submitter <original-submitter@example.com>
+---
+
+diff --git a/b4/bupkes.py b/b4/bupkes.py
+index 12345678..23456789 100644
+--- a/b4/bupkes.py
+--- b/b4/bupkes.py
+@@@ -1,1 +1,1 @@ def bupkes():
+
+
+-bupkes1
++bupkes2
+
+
+--
+2.wong.fu
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer1 <followup-reviewer1@example.com>
+Subject: Re: [PATCH v2 2/2] Simple test 2
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-1@example.com>
+In-Reply-To: <patch-2-message@example.com>
+References: <patch-2-message@example.com> <cover-message@example.com>
+
+> This is a simple trailer parsing test.
+
+Reviewed-by: Followup Reviewer1 <followup-reviewer1@example.com>
+
+--
+My sig
+
+From foo@z Thu Jan 1 00:00:00 1970
+From: Followup Reviewer1 <followup-reviewer1@example.com>
+Subject: Re: [PATCH v2 0/2] Simple cover
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+Message-Id: <fwup-message-2@example.com>
+In-Reply-To: <cover-message@example.com>
+References: <cover-message@example.com>
+
+> This is a simple trailer parsing test.
+
+Reviewed-by: Coverletter Reviewer1 <followup-reviewer1@example.com>
+
+--
+My sig
+
diff --git a/tests/samples/trailers-test-extinfo.txt b/tests/samples/trailers-test-extinfo.txt
new file mode 100644
index 0000000..d36abbb
--- /dev/null
+++ b/tests/samples/trailers-test-extinfo.txt
@@ -0,0 +1,29 @@
+From: Test Test <test@example.com>
+Subject: [PATCH] Simple test
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+
+This is a simple trailer parsing test.
+
+Reviewed-by: Bogus Bupkes <bogus@example.com>
+[for the parts that are bogus]
+Fixes: abcdef01234567890
+Tested-by: Some Person <bogus2@example.com>
+ [this person visually indented theirs]
+Link: https://msgid.link/some@msgid.here # initial submission
+Signed-off-by: Wrapped Persontrailer
+<broken@example.com>
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
diff --git a/tests/samples/trailers-test-simple.txt b/tests/samples/trailers-test-simple.txt
new file mode 100644
index 0000000..693d781
--- /dev/null
+++ b/tests/samples/trailers-test-simple.txt
@@ -0,0 +1,24 @@
+From: Test Test <test@example.com>
+Subject: [PATCH] Simple test
+Date: Tue, 30 Aug 2022 11:19:07 -0400
+
+This is a simple trailer parsing test.
+
+Reviewed-by: Bogus Bupkes <bogus@example.com>
+Fixes: abcdef01234567890
+Link: https://msgid.link/some@msgid.here
+---
+
+diff --git a/b4/junk.py b/b4/junk.py
+index 12345678..23456789 100644
+--- a/b4/junk.py
+--- b/b4/junk.py
+@@@ -1,1 +1,1 @@ def junk():
+
+
+-junk1
++junk2
+
+
+--
+2.wong.fu
diff --git a/tests/test___init__.py b/tests/test___init__.py
index d78667e..4beeb91 100644
--- a/tests/test___init__.py
+++ b/tests/test___init__.py
@@ -1,7 +1,9 @@
import pytest # noqa
import b4
-import re
import os
+import email
+import mailbox
+import io
@pytest.mark.parametrize('source,expected', [
@@ -25,7 +27,6 @@ def test_save_git_am_mbox(tmpdir, source, regex, flags, ismbox):
import re
if source is not None:
if ismbox:
- import mailbox
mbx = mailbox.mbox(f'tests/samples/{source}.txt')
msgs = list(mbx)
else:
@@ -48,3 +49,65 @@ def test_save_git_am_mbox(tmpdir, source, regex, flags, ismbox):
with open(dest, 'r') as fh:
res = fh.read()
assert re.search(regex, res, flags=flags)
+
+
+@pytest.mark.parametrize('source,expected', [
+ ('trailers-test-simple',
+ [('person', 'Reviewed-By', 'Bogus Bupkes <bogus@example.com>', None),
+ ('utility', 'Fixes', 'abcdef01234567890', None),
+ ('utility', 'Link', 'https://msgid.link/some@msgid.here', None),
+ ]),
+ ('trailers-test-extinfo',
+ [('person', 'Reviewed-by', 'Bogus Bupkes <bogus@example.com>', '[for the parts that are bogus]'),
+ ('utility', 'Fixes', 'abcdef01234567890', None),
+ ('person', 'Tested-by', 'Some Person <bogus2@example.com>', ' [this person visually indented theirs]'),
+ ('utility', 'Link', 'https://msgid.link/some@msgid.here', ' # initial submission'),
+ ('person', 'Signed-off-by', 'Wrapped Persontrailer <broken@example.com>', None),
+ ]),
+])
+def test_parse_trailers(source, expected):
+ with open(f'tests/samples/{source}.txt', 'r') as fh:
+ msg = email.message_from_file(fh)
+ lmsg = b4.LoreMessage(msg)
+ gh, m, trs, bas, sig = b4.LoreMessage.get_body_parts(lmsg.body)
+ assert len(expected) == len(trs)
+ for tr in trs:
+ mytype, myname, myvalue, myextinfo = expected.pop(0)
+ mytr = b4.LoreTrailer(name=myname, value=myvalue, extinfo=myextinfo)
+ assert tr == mytr
+ assert tr.type == mytype
+
+
+@pytest.mark.parametrize('source,serargs,amargs,reference,b4cfg', [
+ ('single', {}, {}, 'defaults', {}),
+ ('single', {}, {'noaddtrailers': True}, 'noadd', {}),
+ ('single', {}, {'addmysob': True}, 'addmysob', {}),
+ ('single', {}, {'addmysob': True, 'copyccs': True}, 'copyccs', {}),
+ ('single', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}),
+ ('single', {}, {'addmysob': True, 'copyccs': True}, 'ordered',
+ {'trailer-order': 'Cc,Tested*,Reviewed*,*'}),
+ ('single', {'sloppytrailers': True}, {'addmysob': True}, 'sloppy', {}),
+ ('with-cover', {}, {'addmysob': True}, 'defaults', {}),
+ ('with-cover', {}, {'covertrailers': True, 'addmysob': True}, 'covertrailers', {}),
+ ('custody', {}, {'addmysob': True, 'copyccs': True}, 'unordered', {}),
+ ('custody', {}, {'addmysob': True, 'copyccs': True}, 'ordered',
+ {'trailer-order': 'Cc,Fixes*,Link*,Suggested*,Reviewed*,Tested*,*'}),
+ ('partial-reroll', {}, {'addmysob': True}, 'defaults', {}),
+])
+def test_followup_trailers(source, serargs, amargs, reference, b4cfg):
+ b4.USER_CONFIG = {
+ 'name': 'Test Override',
+ 'email': 'test-override@example.com',
+ }
+ b4.MAIN_CONFIG = dict(b4.DEFAULT_CONFIG)
+ b4.MAIN_CONFIG.update(b4cfg)
+ lmbx = b4.LoreMailbox()
+ for msg in mailbox.mbox(f'tests/samples/trailers-followup-{source}.mbox'):
+ lmbx.add_message(msg)
+ lser = lmbx.get_series(**serargs)
+ assert lser is not None
+ amsgs = lser.get_am_ready(**amargs)
+ ifh = io.StringIO()
+ b4.save_git_am_mbox(amsgs, ifh)
+ with open(f'tests/samples/trailers-followup-{source}-ref-{reference}.txt', 'r') as fh:
+ assert ifh.getvalue() == fh.read()