From d1cc204a33fc4ae0c2c5b7e6fe6aef720454d239 Mon Sep 17 00:00:00 2001 From: Konstantin Ryabitsev Date: Tue, 26 Jul 2022 16:29:17 -0400 Subject: ez: another overhaul of commands and flags Another, hopefully final overhaul of commands and flags: - "b4 ez-series" is now "b4 prep" - "b4 ez-trailers" is now "b4 trailers" - "b4 ez-send" is now "b4 send" I've also split on-disk output into two different commands: b4 prep --format-patch : does not set To/Cc and doesn't do any From magic. In effect, it's as close as it gets to git format-patch output compatibility. b4 send --dry-run -o : generates the messages exactly as they are about to be sent, then writes them out to the directory specified. Signed-off-by: Konstantin Ryabitsev --- b4/__init__.py | 15 ++++- b4/command.py | 102 ++++++++++++++++---------------- b4/ez.py | 179 ++++++++++++++++++++++++++++++++++----------------------- 3 files changed, 168 insertions(+), 128 deletions(-) diff --git a/b4/__init__.py b/b4/__init__.py index 798755c..2a6da91 100644 --- a/b4/__init__.py +++ b/b4/__init__.py @@ -1326,7 +1326,7 @@ class LoreMessage: 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: + if ecode > 0 or not len(out.strip()): return None return out.split(maxsplit=1)[0] @@ -2432,6 +2432,7 @@ def git_range_to_patches(gitdir: Optional[str], start: str, end: str, seriests: Optional[int] = None, mailfrom: Optional[Tuple[str, str]] = None, extrahdrs: Optional[List[Tuple[str, 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}']) @@ -2513,7 +2514,7 @@ def git_range_to_patches(gitdir: Optional[str], start: str, end: str, if counter > 1 and not covermsg: # Tread to the first patch refto = msgid_tpt % str(1) - if refto: + if refto and thread: msg.add_header('References', refto) msg.add_header('In-Reply-To', refto) @@ -2841,7 +2842,11 @@ def patchwork_set_state(msgids: List[str], state: str) -> bool: def send_smtp(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, None], msg: email.message.Message, fromaddr: str, destaddrs: Optional[Union[Tuple, Set]] = None, patatt_sign: bool = False, dryrun: bool = False, - maxheaderlen: Optional[int] = None) -> bool: + maxheaderlen: Optional[int] = None, + write_to: Optional[str] = None) -> bool: + + if write_to is not None: + dryrun = True if not msg.get('X-Mailer'): msg.add_header('X-Mailer', f'b4 {__VERSION__}') msg.set_charset('utf-8') @@ -2875,6 +2880,10 @@ def send_smtp(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, None], msg: email.mess # patatt.logger = logger bdata = patatt.rfc2822_sign(bdata) if dryrun or smtp is None: + if write_to: + with open(write_to, 'wb') as fh: + fh.write(bdata.replace(b'\r\n', b'\n')) + return True logger.info(' --- DRYRUN: message follows ---') logger.info(' | ' + bdata.decode().rstrip().replace('\n', '\n | ')) logger.info(' --- DRYRUN: message ends ---') diff --git a/b4/command.py b/b4/command.py index fb9f6d4..62d9cb1 100644 --- a/b4/command.py +++ b/b4/command.py @@ -71,19 +71,19 @@ def cmd_kr(cmdargs): b4.kr.main(cmdargs) -def cmd_ez_series(cmdargs): +def cmd_prep(cmdargs): import b4.ez - b4.ez.cmd_ez_series(cmdargs) + b4.ez.cmd_prep(cmdargs) -def cmd_ez_trailers(cmdargs): +def cmd_trailers(cmdargs): import b4.ez - b4.ez.cmd_ez_trailers(cmdargs) + b4.ez.cmd_trailers(cmdargs) -def cmd_ez_send(cmdargs): +def cmd_send(cmdargs): import b4.ez - b4.ez.cmd_ez_send(cmdargs) + b4.ez.cmd_send(cmdargs) def cmd_am(cmdargs): @@ -250,57 +250,55 @@ def cmd(): help='Show all developer keys found in a thread') sp_kr.set_defaults(func=cmd_kr) - # b4 ez commands - # ez-series - sp_ezs = subparsers.add_parser('ez-series', help='Simplify work on series submitted for review') - sp_ezs.add_argument('--edit-cover', action='store_true', default=False, - help='Edit the cover letter in your defined $EDITOR (or core.editor)') - sp_ezs.add_argument('--show-revision', action='store_true', default=False, - help='Show current series revision number') - sp_ezs.add_argument('--force-revision', default=False, type=int, - help='Force revision to be this number instead') - ag_ezn = sp_ezs.add_argument_group('Create new branch', 'Create new branch for ez-series') - ag_ezn.add_argument('-n', '--new', dest='new_series_name', - help='Create a new branch and prepare for new series') - ag_ezn.add_argument('-f', '--fork-point', dest='fork_point', - help='When creating a new branch, use this fork point instead of HEAD') - ag_ezn = sp_ezs.add_argument_group('Enroll existing branch', 'Enroll existing branch for ez-series') - ag_ezn.add_argument('-e', '--enroll-with-base', dest='base_branch', - help='Enroll current branch, using the branch passed as parameter as base branch') - sp_ezs.set_defaults(func=cmd_ez_series) - - # ez-trailers - sp_ezt = subparsers.add_parser('ez-trailers', help='Retrieve and apply trailers received for your submission') - sp_ezt.add_argument('-u', '--update-trailers', action='store_true', default=False, - help='Update commits with latest received trailers') - sp_ezt.add_argument('-F', '--trailers-from', dest='msgid', + # 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_prepe = sp_prep.add_argument_group('Enroll existing branch', 'Enroll existing branch for prep work') + ag_prepe.add_argument('-e', '--enroll-with-base', dest='base_branch', + help='Enroll current branch, using the branch passed as parameter as base branch') + 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('-F', '--trailers-from', dest='msgid', help='Look for trailers in the thread with this msgid instead of using the series change-id') - sp_ezt.add_argument('-s', '--signoff', action='store_true', default=False, + sp_trl.add_argument('-s', '--signoff', action='store_true', default=False, help='Add my Signed-off-by trailer, if not already present') - sp_ezt.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False, + sp_trl.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False, help='Apply trailers without email address match checking') - sp_ezt.set_defaults(func=cmd_ez_trailers) + sp_trl.set_defaults(func=cmd_trailers) # ez-send - sp_ezd = subparsers.add_parser('ez-send', help='Submit your series for review on the mailing lists') - ezd_x = sp_ezd.add_mutually_exclusive_group() - ezd_x.add_argument('-o', '--output-dir', - help='Do not send, just write patches into this directory (git-format-patch mode)') - ezd_x.add_argument('-d', '--dry-run', dest='dryrun', action='store_true', default=False, - help='Do not actually send, just dump out raw smtp messages to the stdout') - sp_ezd.add_argument('--prefixes', nargs='+', choices=['RFC', 'WIP', 'RESEND'], - help='Prefixes to add to PATCH (e.g. RFC, WIP, RESEND)') - sp_ezd.add_argument('--no-auto-to-cc', action='store_true', default=False, - help='Do not automatically collect To: and Cc: addresses') - sp_ezd.add_argument('--to', nargs='+', - help='Addresses to add to the automatically collected To: list') - sp_ezd.add_argument('--cc', nargs='+', - help='Addresses to add to the automatically collected Cc: list') - sp_ezd.add_argument('--not-me-too', action='store_true', default=False, - help='Remove yourself from the To: or Cc: list') - sp_ezd.add_argument('--no-sign', action='store_true', default=False, - help='Do not cryptographically sign your patches with patatt') - sp_ezd.set_defaults(func=cmd_ez_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='+', choices=['RFC', 'WIP', 'RESEND'], + help='Prefixes to add to PATCH (e.g. RFC, WIP, RESEND)') + 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('--no-sign', action='store_true', default=False, + help='Do not cryptographically sign your patches with patatt') + sp_send.set_defaults(func=cmd_send) cmdargs = parser.parse_args() diff --git a/b4/ez.py b/b4/ez.py index ec331d0..7851929 100644 --- a/b4/ez.py +++ b/b4/ez.py @@ -233,7 +233,7 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: # Try loading existing cover info cover, jdata = load_cover() - logger.info('Will track %s commits for ez-series', commitcount) + logger.info('Will track %s commits', commitcount) else: logger.critical('CRITICAL: unknown operation requested') sys.exit(1) @@ -255,7 +255,7 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: '', '# 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.', + '# the b4 send stage.', '', '', ) @@ -276,7 +276,7 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: 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') + '# This section is used internally by b4 prep for tracking purposes.\n') return mj + json.dumps(data, indent=2) @@ -358,20 +358,20 @@ def get_cover_strategy(branch: Optional[str] = None) -> str: 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') + if 'b4-prep-cover-strategy' in bconfig: + strategy = bconfig.get('b4-prep-cover-strategy') else: config = b4.get_main_config() - strategy = config.get('ez-cover-strategy', 'commit') + strategy = config.get('prep-cover-strategy', 'commit') if strategy in {'commit', 'branch-description'}: return strategy - logger.critical('CRITICAL: unknown ez-cover-strategy: %s', strategy) + logger.critical('CRITICAL: unknown prep-cover-strategy: %s', strategy) sys.exit(1) -def is_ez_branch() -> bool: +def is_prep_branch() -> bool: mybranch = b4.git_get_current_branch() strategy = get_cover_strategy(mybranch) if strategy == 'commit': @@ -489,10 +489,15 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: else: signoff = None - # If we are in an ez-series branch, we start from the beginning of the series + # If we are in an b4-prep 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: + if is_prep_branch(): + start = get_series_start() + end = 'HEAD' + cover, tracking = load_cover(strip_comments=True) + changeid = tracking['series'].get('change-id') + elif cmdargs.msgid: changeid = None myemail = usercfg['email'] # There doesn't appear to be a great way to find the first commit @@ -518,13 +523,6 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: 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) @@ -675,32 +673,21 @@ def print_pretty_addrs(addrs: list, hdrname: str) -> None: logger.info(' %s', b4.format_addrs([addr])) -def cmd_ez_send(cmdargs: argparse.Namespace) -> None: - # Check if the cover letter has 'EDITME' in it +def get_prep_branch_as_patches(prefixes: Optional[list] = None, + movefrom: bool = True, + thread: bool = True) -> List[Tuple[str, email.message.Message]]: 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'): + if config.get('prep-cover-template'): # Try to load this template instead try: - cover_template = b4.read_template(config['submit-cover-template']) + cover_template = b4.read_template(config['prep-cover-template']) except FileNotFoundError: - logger.critical('ERROR: submit-cover-template says to use %s, but it does not exist', - config['submit-cover-template']) + logger.critical('ERROR: prep-cover-template says to use %s, but it does not exist', + config['prep-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() @@ -720,9 +707,7 @@ def cmd_ez_send(cmdargs: argparse.Namespace) -> None: cmsg = email.message.EmailMessage() cmsg.add_header('Subject', csubject) cmsg.set_payload(body, charset='utf-8') - if cmdargs.prefixes: - prefixes = list(cmdargs.prefixes) - else: + if prefixes is None: prefixes = list() prefixes.append(f'v{revision}') @@ -741,28 +726,76 @@ def cmd_ez_send(cmdargs: argparse.Namespace) -> None: # Message-IDs must not be predictable to avoid stuffing attacks randompart = uuid.uuid4().hex[:12] msgid_tpt = f'<{stablepart}-v{revision}-%s-{randompart}@{msgdomain}>' + if movefrom: + mailfrom = (myname, myemail) + else: + mailfrom = None + patches = b4.git_range_to_patches(None, start_commit, 'HEAD', + covermsg=cmsg, prefixes=prefixes, + msgid_tpt=msgid_tpt, + seriests=seriests, + thread=thread, + mailfrom=mailfrom) + return patches + + +def format_patch(output_dir: str) -> None: try: - patches = b4.git_range_to_patches(None, start_commit, 'HEAD', - covermsg=cmsg, prefixes=prefixes, - msgid_tpt=msgid_tpt, - seriests=seriests, - mailfrom=(myname, myemail)) + 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: + # 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) + + trailers = set() + parts = b4.LoreMessage.get_body_parts(cover) + trailers.update(parts[2]) + + try: + patches = get_prep_branch_as_patches(prefixes=cmdargs.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() - if config.get('submit-to'): - for pair in utils.getaddresses([config.get('submit-to')]): + 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('submit-cc'): - for pair in utils.getaddresses([config.get('submit-cc')]): + 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) @@ -846,23 +879,9 @@ def cmd_ez_send(cmdargs: argparse.Namespace) -> None: 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) - 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=0)) - logger.info(' %s', filen) - return # Give the user the last opportunity to bail out if not cmdargs.dryrun: @@ -884,7 +903,7 @@ def cmd_ez_send(cmdargs: argparse.Namespace) -> None: # And now we go through each message to set addressees and send them off sign = True - if cmdargs.no_sign or config.get('ez-send-no-sign', '').lower() in {'yes', 'true', 'y'}: + if cmdargs.no_sign or config.get('send-no-patatt-sign', '').lower() in {'yes', 'true', 'y'}: sign = False identity = config.get('sendemail-identity') try: @@ -906,14 +925,23 @@ def cmd_ez_send(cmdargs: argparse.Namespace) -> None: # 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))) + msg.add_header('X-b4-tracking', ' '.join(textwrap.wrap(b64tracking.decode(), width=78))) 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 cmdargs.output_dir: + subject = msg.get('Subject', '') + ls = b4.LoreSubject(subject) + filen = '%s.eml' % ls.get_slug(sep='-') + logger.info(' %s', filen) + write_to = os.path.join(cmdargs.output_dir, filen) + else: + write_to = None + logger.info(' %s', re.sub(r'\s+', ' ', msg.get('Subject'))) + if b4.send_smtp(smtp, msg, fromaddr=fromaddr, destaddrs=alldests, patatt_sign=sign, - dryrun=cmdargs.dryrun): + dryrun=cmdargs.dryrun, write_to=write_to): counter += 1 logger.info('---') @@ -957,6 +985,7 @@ def cmd_ez_send(cmdargs: argparse.Namespace) -> None: tagcommit = 'HEAD' # TODO: make sent/ prefix configurable? + revision = tracking['series']['revision'] tagprefix = 'sent/' if mybranch.startswith('b4/'): tagname = f'{tagprefix}{mybranch[3:]}-v{revision}' @@ -982,7 +1011,7 @@ def cmd_ez_send(cmdargs: argparse.Namespace) -> None: if vrev not in tracking['series']['history']: tracking['series']['history'][vrev] = list() tracking['series']['history'][vrev].append(cover_msgid) - if 'RESEND' in prefixes: + if cmdargs.prefixes and 'RESEND' in cmdargs.prefixes: logger.info('Not incrementing current revision due to RESEND') store_cover(cover, tracking) return @@ -1049,7 +1078,7 @@ def force_revision(forceto: int) -> None: store_cover(cover, tracking) -def cmd_ez_series(cmdargs: argparse.Namespace) -> None: +def cmd_prep(cmdargs: argparse.Namespace) -> None: check_can_gfr() status = b4.git_get_repo_status() if len(status): @@ -1066,14 +1095,17 @@ def cmd_ez_series(cmdargs: argparse.Namespace) -> None: if cmdargs.force_revision: return force_revision(cmdargs.force_revision) - if is_ez_branch(): - logger.critical('CRITICAL: This appears to already be an ez-series branch.') + 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_ez_trailers(cmdargs: argparse.Namespace) -> None: +def cmd_trailers(cmdargs: argparse.Namespace) -> None: check_can_gfr() status = b4.git_get_repo_status() if len(status): @@ -1081,4 +1113,5 @@ def cmd_ez_trailers(cmdargs: argparse.Namespace) -> None: logger.critical(' Stash or commit them first.') sys.exit(1) - update_trailers(cmdargs) + if cmdargs.update: + update_trailers(cmdargs) -- cgit v1.2.3