diff options
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----- @@ -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) @@ -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 @@ -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: @@ -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-') @@ -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 @@ -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() |