#!/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 ' 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 # from nacl.signing import SigningKey # from nacl.encoding import Base64Encoder 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 auth_new(cmdargs: argparse.Namespace) -> None: # # Check if we have a patatt signingkey already defined # endpoint, name, email, ptskey = get_configs() # skey, pkey = get_patatt_ed25519keys(ptskey) # logger.info('Will submit a new email authorization request to:') # logger.info(' Endpoint: %s', endpoint) # logger.info(' Name: %s', name) # logger.info(' Email: %s', email) # logger.info(' Key: %s (%s)', pkey, ptskey) # logger.info('---') # confirm = input('Confirm selection [y/N]: ') # if confirm != 'y': # logger.info('Exiting') # sys.exit(0) # req = { # 'action': 'auth-new', # 'name': name, # 'email': email, # 'key': pkey, # } # 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', email) # logger.info('Once you receive it, run b4 submit --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: # endpoint, name, email, ptskey = get_configs() # skey, pkey = get_patatt_ed25519keys(ptskey) # challenge = cmdargs.auth_verify # logger.info('Signing challenge using key %s', ptskey) # sk = SigningKey(skey.encode(), encoder=Base64Encoder) # bdata = sk.sign(challenge.encode(), encoder=Base64Encoder) # req = { # 'action': 'auth-verify', # 'name': name, # 'email': email, # 'challenge': challenge, # 'sigdata': bdata.decode(), # } # 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', email) # 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_base_forkpoint(basebranch: str) -> Tuple[str, int]: # Check that that branch exists gitargs = ['rev-parse', '--verify', '--quiet', basebranch] ecode, out = b4.git_run_command(None, gitargs) if ecode > 0: logger.crtitical('CRITICAL: Could not find branch with this name: %s', basebranch) raise RuntimeError('Branch %s not found', basebranch) # Find merge-base with that branch 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.crtitical('CRITICAL: Could not find common ancestor with %s', basebranch) raise RuntimeError('Branches %s and %s have no common ancestors', basebranch, mybranch) fp = lines[0] logger.debug('Fork-point between %s and %s is %s', mybranch, basebranch, fp) # Check how many revisions there are between the fork-point and the current HEAD gitargs = ['rev-list', f'{fp}..'] lines = b4.git_get_command_lines(None, gitargs) # Arbitrarily, set it to 1000 if len(lines) > 1000: logger.critical('CRITICAL: Too many revisions between %s and current branch: %s', basebranch, len(lines)) raise RuntimeError('Branches %s and %s are unreasonable as ancestors', basebranch, mybranch) return fp, len(lines) 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 = None if cmdargs.new_series_name: basebranch = None if not cmdargs.fork_point: cmdargs.fork_point = 'HEAD' else: strategy = get_cover_strategy() # if our strategy is not "commit", then we need to know which branch we're using as base mybranch = b4.git_get_current_branch() 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.base_branch: # Check that strategy isn't "commit" as we don't currently support that if get_cover_strategy() == 'commit': logger.critical('CRITICAL: enrolling branches with "commit" cover strategy is not currently supported') sys.exit(1) seriesname = b4.git_get_current_branch() slug = re.sub(r'\W+', '-', seriesname).strip('-').lower() basebranch = cmdargs.base_branch try: forkpoint, commitcount = get_base_forkpoint(basebranch) except RuntimeError: sys.exit(1) # Try loading existing cover info cover, jdata = load_cover() logger.info('Will track %s commits for ez-series', commitcount) else: logger.critical('CRITICAL: unknown operation requested') sys.exit(1) 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 ez-send stage.', '', '', ) cover = '\n'.join(cover) logger.info('Created the default cover letter, you can edit with --edit-cover.') # 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]) tracking = { 'series': { 'revision': 1, 'change-id': changeid, 'base-branch': basebranch, }, } store_cover(cover, tracking, new=True) def make_magic_json(data: dict) -> str: mj = (f'{MAGIC_MARKER}\n' '# This section is used internally by b4 ez-series 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 == 'commit': cover_commit = find_cover_commit() if not cover_commit: logger.critical('CRITICAL: unable to find cover commit!') sys.exit(1) 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) jdata = 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', '') jdata = json.loads(bcfg.get('b4-tracking', '{}')) else: # TODO: implement logger.critical('Not yet supported for %s cover strategy', strategy) sys.exit(0) logger.debug('tracking data: %s', jdata) 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(), jdata def store_cover(content: str, tracking: dict, new: bool = False) -> None: strategy = get_cover_strategy() if strategy == '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.') 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-ez-cover-strategy' in bconfig: strategy = bconfig.get('b4-ez-cover-strategy') else: config = b4.get_main_config() strategy = config.get('ez-cover-strategy', 'commit') return strategy def is_ez_branch() -> bool: mybranch = b4.git_get_current_branch() strategy = get_cover_strategy(mybranch) if strategy == '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 if strategy == 'tag': logger.critical('CRITICAL: tag strategy not yet supported') sys.exit(1) 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() 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) base_branch = jdata['series']['base-branch'] # Find merge-base with the tracking branch logger.debug('Finding the fork-point with %s', base_branch) gitargs = ['merge-base', '--fork-point', base_branch] lines = b4.git_get_command_lines(None, gitargs) if not lines: logger.critical('CRITICAL: Could not find fork-point with base branch %s', base_branch) sys.exit(1) return lines[0] # other strategies not yet implemented logger.critical('CRITICAL: strategy %s not yet implemented', get_cover_strategy()) sys.exit(1) 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) if cmdargs.signoff: signoff = ('Signed-off-by', f"{usercfg['name']} <{usercfg['email']}>", None) else: signoff = None # If we are in an ez-series branch, we start from the beginning of the series # oterwise, we start at the first commit where we're the committer since 3.months # TODO: consider making that settable? if cmdargs.msgid: 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 "3.months" where # we're the committer and stop at the first non-contiguous parent gitargs = ['log', '-F', f'--committer={myemail}', '--since=3.months', '--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 = None end = None commit = None for line in lines: commit, parent = line.split() if end is None: end = commit if prevparent is None: prevparent = parent continue if prevparent != commit: break prevparent = parent start = f'{commit}~1' elif is_ez_branch(): start = get_series_start() end = 'HEAD' cover, tracking = load_cover(strip_comments=True) changeid = tracking['series'].get('change-id') 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) 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() # Ignore the cover letter for commit, msg in patches[1:]: 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 parts = b4.LoreMessage.get_body_parts(body) # Force SOB update if signoff and (signoff not in parts[2] or (len(signoff) > 1 and parts[2][-1] != signoff)): updates[commit] = list() if signoff not in parts[2]: updates[commit].append(signoff) if cmdargs.msgid: msgid = b4.get_msgid(cmdargs) logger.info('Retrieving thread matching %s', msgid) list_msgs = b4.get_pi_thread_by_msgid(msgid, nocache=True) elif changeid: logger.info('Checking change-id "%s"', changeid) query = f'"change-id: {changeid}"' list_msgs = b4.get_pi_search_results(query, nocache=True) else: list_msgs = None 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(lmsg.body) for ftrailer in addtrailers: if ftrailer[:3] not in parts[2]: if commit not in updates: updates[commit] = list() updates[commit].append(ftrailer) # Check if we've applied mismatched trailers already if not cmdargs.sloppytrailers and mismatches: for mtrailer in list(mismatches): check = (mtrailer[0], mtrailer[1], None) if check in parts[2]: logger.debug('Removing already-applied mismatch %s', check) mismatches.remove(mtrailer) 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 if signoff in newtrailers: logger.info(' + %s: %s', signoff[0], signoff[1]) elif signoff: logger.info(' > %s: %s', signoff[0], signoff[1]) cmsg.fix_trailers(signoff=signoff) 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]]: ecode, out, err = b4._run_command(cmdargs, stdin=msgbytes) # 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] gitargs = ['shortlog', f'{start_commit}..'] ecode, shortlog = b4.git_run_command(None, gitargs) gitargs = ['diff', '--stat', f'{start_commit}..'] ecode, diffstat = b4.git_run_command(None, gitargs) return base_commit, shortlog.rstrip(), diffstat.rstrip() def cmd_ez_send(cmdargs: argparse.Namespace) -> None: # 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) config = b4.get_main_config() cover_template = DEFAULT_COVER_TEMPLATE if config.get('submit-cover-template'): # Try to load this template instead try: cover_template = b4.read_template(config['submit-cover-template']) except FileNotFoundError: logger.critical('ERROR: submit-cover-template says to use %s, but it does not exist', config['submit-cover-template']) sys.exit(2) # Generate the patches and collect all the addresses from trailers parts = b4.LoreMessage.get_body_parts(cover) trailers = set() trailers.update(parts[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) # 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))) cmsg.set_payload(body, charset='utf-8') if cmdargs.prefixes: prefixes = list(cmdargs.prefixes) else: prefixes = list() prefixes.append(f'v{revision}') seriests = int(time.time()) usercfg = b4.get_user_config() myemail = usercfg.get('email') myname = usercfg.get('name') if myemail: msgdomain = re.sub(r'^[^@]*@', '', myemail) else: # Use the hostname of the system import platform msgdomain = 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}@{msgdomain}>' try: patches = b4.git_range_to_patches(None, start_commit, 'HEAD', covermsg=cmsg, prefixes=prefixes, msgid_tpt=msgid_tpt, seriests=seriests, mailfrom=(myname, myemail)) 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) seen = set() todests = list() if config.get('submit-to'): for pair in utils.getaddresses([config.get('submit-to')]): if pair[1] not in seen: seen.add(pair[1]) todests.append(pair) ccdests = list() if config.get('submit-cc'): for pair in utils.getaddresses([config.get('submit-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') # Use a sane tocmd and cccmd for the kernel # TODO: make it definable in the config tocmdstr = tocmd = None cccmdstr = cccmd = None topdir = b4.git_get_toplevel() getm = os.path.join(topdir, 'scripts', 'get_maintainer.pl') if os.access(getm, os.X_OK): logger.debug('Using kernel get_maintainer.pl for to and cc list') tocmdstr = f'{getm} --nogit --nogit-fallback --nogit-chief-penguins --norolestats --nol' 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 tocmd: for pair in get_addresses_from_cmd(tocmd, msgbytes): if pair[1] not in seen: seen.add(pair[1]) todests.append(pair) if 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 trailer in trailers: if '@' in trailer[1]: for pair in utils.getaddresses([trailer[1]]): if pair[1] not in seen: seen.add(pair[1]) ccdests.append(pair) 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: pathlib.Path(cmdargs.output_dir).mkdir(parents=True, exist_ok=True) for commit, msg in patches: if not msg: continue msg.add_header('To', b4.format_addrs(allto)) if allcc: msg.add_header('Cc', b4.format_addrs(allcc)) msg.set_charset('utf-8') msg.replace_header('Content-Transfer-Encoding', '8bit') 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(cmdargs.output_dir, filen), 'w') as fh: fh.write(msg.as_string(unixfrom=True, maxheaderlen=80)) logger.info(' %s', filen) return # And now we go through each message to set addressees and send them off sign = True if cmdargs.no_sign or config.get('submit-no-sign', '').lower() in {'yes', 'true', 'y'}: sign = False 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) counter = 0 cover_msgid = None # TODO: Need to send obsoleted-by follow-ups, just need to figure out where. for commit, msg in patches: if not msg: continue if cover_msgid is None: cover_msgid = b4.LoreMessage.get_clean_msgid(msg) msg.add_header('To', b4.format_addrs(allto)) if allcc: msg.add_header('Cc', b4.format_addrs(allcc)) logger.info(' %s', msg.get('Subject')) if b4.send_smtp(smtp, msg, fromaddr=fromaddr, destaddrs=alldests, patatt_sign=sign, dryrun=cmdargs.dryrun): counter += 1 logger.info('---') if cmdargs.dryrun: logger.info('DRYRUN: Would have sent %s messages', counter) return else: logger.info('Sent %s messages', counter) 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(): 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 cmd_ez_series(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 is_ez_branch(): logger.critical('CRITICAL: This appears to already be an ez-series branch.') sys.exit(1) return start_new_series(cmdargs) def cmd_ez_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) update_trailers(cmdargs)