pax_global_header00006660000000000000000000000064145634545350014530gustar00rootroot0000000000000052 comment=275929049fea2b702d067c86add601a10d08f500 b4-0.13.0/000077500000000000000000000000001456345453500121165ustar00rootroot00000000000000b4-0.13.0/.b4-config000066400000000000000000000004431456345453500136700ustar00rootroot00000000000000[b4] midmask = https://lore.kernel.org/%s linkmask = https://msgid.link/%s send-series-to = Kernel.org Tools send-series-cc = Konstantin Ryabitsev send-endpoint-web = https://lkml.kernel.org/_b4_submit send-prefixes = b4 b4-0.13.0/.gitignore000066400000000000000000000002401456345453500141020ustar00rootroot00000000000000*.swp *.pyc *.pyo *.json *.pdf test.log build/* dist/* MANIFEST .idea __pycache__ *.egg-info *.patch *.mbx *.maildir *.cover *.thanks .venv qodana.yaml *.ipynb b4-0.13.0/.gitmodules000066400000000000000000000001421456345453500142700ustar00rootroot00000000000000[submodule "patatt"] path = patatt url = https://git.kernel.org/pub/scm/utils/patatt/patatt.git b4-0.13.0/.keys/000077500000000000000000000000001456345453500131475ustar00rootroot00000000000000b4-0.13.0/.keys/ed25519/000077500000000000000000000000001456345453500141455ustar00rootroot00000000000000b4-0.13.0/.keys/ed25519/t-8ch.de/000077500000000000000000000000001456345453500154575ustar00rootroot00000000000000b4-0.13.0/.keys/ed25519/t-8ch.de/thomas/000077500000000000000000000000001456345453500167525ustar00rootroot00000000000000b4-0.13.0/.keys/ed25519/t-8ch.de/thomas/20221212000066400000000000000000000000551456345453500175700ustar00rootroot00000000000000KcycQgFPX2wGR5azS7RhpBqedglOZVgRPfdFSPB1LNw= b4-0.13.0/.keys/openpgp/000077500000000000000000000000001456345453500146175ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/chromium.org/000077500000000000000000000000001456345453500172305ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/chromium.org/keescook/000077500000000000000000000000001456345453500210335ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/chromium.org/keescook/default000066400000000000000000002434201456345453500224070ustar00rootroot00000000000000pub rsa4096 2010-09-27 [SC] A5C3F68F229DD60F723E6E138972F4DFDC6DC026 uid Kees Cook uid Kees Cook uid Kees Cook uid Kees Cook uid Kees Cook uid Kees Cook 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----- b4-0.13.0/.keys/openpgp/linuxfoundation.org/000077500000000000000000000000001456345453500206335ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/linuxfoundation.org/konstantin/000077500000000000000000000000001456345453500230235ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/linuxfoundation.org/konstantin/default000066400000000000000000000561461456345453500244060ustar00rootroot00000000000000-----BEGIN PGP PUBLIC KEY BLOCK----- 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 eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS 09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAA AAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEH YXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVG R0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKj pKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX2 9/j5+v/aAAwDAQACEQMRAD8A+y6KKKACiiory4gtLWW6uZVihhQvI7HAVQMkn8KA JaxvEPivw34fXdrWt2Nj6LLMAx+i9TXyl8bf2kNb1G7n0jwSz6fYqxT7SBiaUeoP 8I+nNfP13b+IPEN0097qshkckszuWLfnQB+g4+M3w4M4hXxJEzHoRE5H8q6HSvGf hfVHVLLWbWRm+6CSufzxX53aL4dmtCHlvd7A54Y816r4JvprZESQs6AjkHnHrQB9 tg5GRRXAfCHxBNqNlJpt05kaFBJC5OSUPBB+h/nXf0AFFFFABRRRQAUUUUAFcJ+0 BLND8HvEbwOUk+y4BB9WUEfka5r4nftFfDnwLfzabc30upahCdssNmAwjb0ZyQAf YZrw74tftV+F/GXgbUfDVno2oWTXgRftDOrgKHDH5RjrjHWgDwwmJrlpT97uc8fh U6X0SOpjkAI64NctHdaHNcyTXOr3JTf8sIh8vK+7c4P4Vct77QwcW+nRXOAeZL1x k54P3R+X60AdjYapaGVVMjOOpx2Ndb4av1O4xSlVJ43L09q85025aYDbYeHICVx+ +vApB9e/Htz9a6zR4tZZg1rH4UYEYYJfE/iBjj6cigD6B+FfjCLSdXtpXdXib91L g8bT1x+hr6TjdZI1kRgysAQR3Br4o8OWPiMeWWstBbJyfKmbOc/7np/kV9H/AAp8 XTtY2+h69CLe4jUJbyrlo3UDoW/hI7ZxmgD0qio2mhVdzSoF9SwxTba6trnd9nni l2nDbGDY/KgCaiiigAryv9qvxpd+Bfgtq+q6dKYr+4KWVtIOsbSHBYe4UMR716pX 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 =chX+ -----END PGP PUBLIC KEY BLOCK----- b4-0.13.0/.keys/openpgp/pbarker.dev/000077500000000000000000000000001456345453500170225ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/pbarker.dev/paul/000077500000000000000000000000001456345453500177635ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/pbarker.dev/paul/default000066400000000000000000000066601456345453500213420ustar00rootroot00000000000000-----BEGIN PGP PUBLIC KEY BLOCK----- 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 =907n -----END PGP PUBLIC KEY BLOCK----- b4-0.13.0/.keys/openpgp/tessares.net/000077500000000000000000000000001456345453500172355ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/tessares.net/matthieu.baerts/000077500000000000000000000000001456345453500223345ustar00rootroot00000000000000b4-0.13.0/.keys/openpgp/tessares.net/matthieu.baerts/default000066400000000000000000000167361456345453500237200ustar00rootroot00000000000000pub rsa4096 2015-08-31 [SC] E8CB85F76877057A6E27F77AF6B7824F4269A073 uid Matthieu Baerts uid Matthieu Baerts sub rsa4096 2015-08-31 [E] 5B1A4BFBA06327FAC85B89272F9C14FD7EDF0E65 sub ed25519 2021-08-26 [S] 1B86596F99E77A0D744A4E387C22F0C2F3470A97 -----BEGIN PGP PUBLIC KEY BLOCK----- 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 =CUrO -----END PGP PUBLIC KEY BLOCK----- b4-0.13.0/.readthedocs.yaml000066400000000000000000000020041456345453500153410ustar00rootroot00000000000000# Read the Docs configuration file for Sphinx projects # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the OS, Python version and other tools you might need build: os: ubuntu-22.04 tools: python: "3.12" # You can also specify other tool versions: # nodejs: "20" # rust: "1.70" # golang: "1.20" # Build documentation in the "docs/" directory with Sphinx sphinx: configuration: docs/conf.py # You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs # builder: "dirhtml" # Fail on all warnings to avoid broken references # fail_on_warning: true # Optionally build your docs in additional formats such as PDF and ePub # formats: # - pdf # - epub # Optional but recommended, declare the Python requirements required # to build your documentation # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html python: install: - requirements: docs/requirements.txt b4-0.13.0/CONTRIBUTING.rst000066400000000000000000000003131456345453500145540ustar00rootroot00000000000000Submitting Patches to b4 ======================== The current b4 source code can be found at:: git clone https://git.kernel.org/pub/scm/utils/b4/b4.git/ patches can be sent to:: tools@kernel.org b4-0.13.0/COPYING000066400000000000000000000432541456345453500131610ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. b4-0.13.0/MANIFEST.in000066400000000000000000000000641456345453500136540ustar00rootroot00000000000000include COPYING include man/*.rst include *.example b4-0.13.0/README.rst000066400000000000000000000023611456345453500136070ustar00rootroot00000000000000B4 tools ======== This is a helper utility to work with patches made available via a public-inbox archive like lore.kernel.org. It is written to make it easier to participate in a patch-based workflows, like those used in the Linux kernel development. The name "b4" was chosen for ease of typing and because B-4 was the precursor to Lore and Data in the Star Trek universe. See https://b4.docs.kernel.org/ for online documentation. Installing ---------- To install from pypi:: python3 -m pip install --user b4 Upgrading --------- If you previously installed from pypi:: python3 -m pip install --user --upgrade b4 Running from the checkout dir ----------------------------- If you want to run from the checkout dir without installing the python package, you can use the included ``b4.sh`` wrapper. You can set it as an alias in your .bash_profile:: alias b4="$HOME/path/to/b4/b4.sh" Setting up a symlink should also be possible. Remember to run the following command after the initial clone in order to pull in the dependencies that are tracked via submodules:: git submodule update --init Support ------- For support or with any other questions, please email tools@kernel.org, or browse the list archive at https://lore.kernel.org/tools. b4-0.13.0/b4.sh000077500000000000000000000004331456345453500127620ustar00rootroot00000000000000#!/usr/bin/env bash # # Run b4 from a git checkout. # REAL_SCRIPT=$(realpath -e ${BASH_SOURCE[0]}) SCRIPT_TOP="${SCRIPT_TOP:-$(dirname ${REAL_SCRIPT})}" PYTHONPATH="${SCRIPT_TOP}:${SCRIPT_TOP}/patatt${PYTHONPATH:+:$PYTHONPATH}" \ exec python3 "${SCRIPT_TOP}/b4/command.py" "${@}" b4-0.13.0/b4/000077500000000000000000000000001456345453500124235ustar00rootroot00000000000000b4-0.13.0/b4/__init__.py000066400000000000000000004717161456345453500145540ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2020 by the Linux Foundation import subprocess import logging import hashlib import re import sys import gzip import os import fnmatch import email.utils import email.policy import email.header import email.generator import email.quoprimime import tempfile import pathlib import argparse import smtplib import shlex import textwrap import urllib.parse import datetime import time import copy import shutil import mailbox # noinspection PyCompatibility import pwd import io import requests from pathlib import Path from contextlib import contextmanager from typing import Optional, Tuple, Set, List, BinaryIO, Union, Sequence, Literal, Iterator, Dict from email import charset charset.add_charset('utf-8', None) # Policy we use for saving mail locally emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length=None) # Presence of these characters requires quoting of the name in the header # adapted from email._parseaddr qspecials = re.compile(r'[()<>@,:;.\"\[\]]') try: import dkim can_dkim = True except ModuleNotFoundError: can_dkim = False try: import patatt can_patatt = True except ModuleNotFoundError: can_patatt = False # global setting allowing us to turn off networking can_network = True __VERSION__ = '0.13.0' PW_REST_API_VERSION = '1.2' def _dkim_log_filter(record): # Hide all dkim logging output in normal operation by setting the level to # DEBUG. If debugging output has been enabled then prefix dkim logging # output to make its origin clear. record.levelno = logging.DEBUG record.levelname = 'DEBUG' record.msg = 'DKIM: ' + record.msg return True logger = logging.getLogger('b4') dkimlogger = logger.getChild('dkim') dkimlogger.addFilter(_dkim_log_filter) HUNK_RE = re.compile(r'^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@') FILENAME_RE = re.compile(r'^(---|\+\+\+) (\S+)') DIFF_RE = re.compile(r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I) DIFFSTAT_RE = re.compile(r'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.M | re.I) ATT_PASS_SIMPLE = 'v' ATT_FAIL_SIMPLE = 'x' ATT_PASS_FANCY = '\033[32m\u2713\033[0m' ATT_FAIL_FANCY = '\033[31m\u2717\033[0m' CI_FLAGS_SIMPLE = { 'pending': '', 'success': '', 'warning': '', 'failure': '', } CI_FLAGS_FANCY = { 'pending': '\033[90m\u25cf\033[0m', 'success': '\033[32m\u25cf\033[0m', 'warning': '\033[31m\u25cf\033[0m', 'fail': '\033[31;5m\u25cf\033[0m', } DEVSIG_HDR = 'X-Developer-Signature' LOREADDR = 'https://lore.kernel.org' DEFAULT_CONFIG = { 'midmask': LOREADDR + '/all/%s', 'linkmask': LOREADDR + '/r/%s', 'searchmask': LOREADDR + '/all/?x=m&t=1&q=%s', # You can override the format for the Link: trailer, e.g. # if you would rather use the Message-Id trailer. It takes the # message-id as the expansion for %s # linktrailermask = Message-Id: <%s> 'listid-preference': '*.feeds.kernel.org,*.linux.dev,*.kernel.org,*', 'save-maildirs': 'no', # off: do not bother checking attestation # check: print an attaboy when attestation is found # softfail: print a warning when no attestation found # hardfail: exit with an error when no attestation found 'attestation-policy': 'softfail', # How many days before we consider attestation too old? 'attestation-staleness-days': '30', # Should we check DKIM signatures if we don't find any other attestation? 'attestation-check-dkim': 'yes', # We'll use the default gnupg homedir, unless you set it here 'attestation-gnupghome': None, # Do you like simple or fancy checkmarks? 'attestation-checkmarks': 'fancy', # How long to keep things in cache before expiring (minutes)? 'cache-expire': '10', # Used when creating summaries for b4 ty 'thanks-commit-url-mask': None, # See thanks-pr-template.example 'thanks-pr-template': None, # See thanks-am-template.example 'thanks-am-template': None, # If this is not set, we'll use what we find in # 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 MAIN_CONFIG: Dict[str, Optional[Union[str, List[str]]]] = dict() # This is git-config user.* USER_CONFIG: Dict[str, Optional[Union[str, List[str]]]] = dict() # This is git-config sendemail.* SENDEMAIL_CONFIG: Dict[str, Optional[Union[str, List[str]]]] = dict() # Used for storing our requests session REQSESSION = None # Indicates that we've cleaned cache already _CACHE_CLEANED = False # Used to track mailmap replacements MAILMAP_INFO = dict() class LoreMailbox: msgid_map: Dict[str, 'LoreMessage'] series: Dict[int, 'LoreSeries'] covers: Dict[int, 'LoreMessage'] followups: List['LoreMessage'] unknowns: List['LoreMessage'] def __init__(self): self.msgid_map = dict() self.series = dict() self.covers = dict() self.trailer_map = dict() self.followups = list() self.unknowns = list() def __repr__(self): out = list() for key, lser in self.series.items(): out.append(str(lser)) out.append('--- Followups ---') for lmsg in self.followups: out.append(' %s' % lmsg.full_subject) out.append('--- Unknowns ---') for lmsg in self.unknowns: out.append(' %s' % lmsg.full_subject) return '\n'.join(out) def get_by_msgid(self, msgid: str) -> Optional['LoreMessage']: if msgid in self.msgid_map: return self.msgid_map[msgid] return None def partial_reroll(self, revision, sloppytrailers): # Is it a partial reroll? # To qualify for a partial reroll: # 1. Needs to be version > 1 # 2. Replies need to be to the exact X/N of the previous revision if revision <= 1 or revision - 1 not in self.series: return # Are existing patches replies to previous revisions with the same counter? pser = self.get_series(revision - 1, sloppytrailers=sloppytrailers) lser = self.series[revision] sane = True for patch in lser.patches: if patch is None: continue if patch.in_reply_to is None or patch.in_reply_to not in self.msgid_map: logger.debug('Patch not sent as a reply-to') sane = False break ppatch = self.msgid_map[patch.in_reply_to] found = False while True: if patch.counter == ppatch.counter and patch.expected == ppatch.expected: logger.debug('Found a previous matching patch in v%s', ppatch.revision) found = True break # Do we have another level up? if ppatch.in_reply_to is None or ppatch.in_reply_to not in self.msgid_map: break ppatch = self.msgid_map[ppatch.in_reply_to] if not found: sane = False logger.debug('Patch not a reply to a patch with the same counter/expected (%s/%s != %s/%s)', patch.counter, patch.expected, ppatch.counter, ppatch.expected) break if not sane: logger.debug('Not a sane partial reroll') return logger.info('Partial reroll detected, reconstituting from v%s', pser.revision) logger.debug('Reconstituting a partial reroll') at = 0 for patch in lser.patches: if pser.patches[at] is None: at += 1 continue if patch is None: ppatch = copy.deepcopy(pser.patches[at]) ppatch.revision = lser.revision ppatch.reroll_from_revision = pser.revision lser.patches[at] = ppatch else: patch.reroll_from_revision = lser.revision at += 1 if None not in lser.patches[1:]: lser.complete = True lser.partial_reroll = True if lser.patches[0] is not None: lser.has_cover = True lser.subject = pser.subject logger.debug('Reconstituted successfully') def load_codereview_trailers(self) -> None: q = list() for lver, lser in self.series.items(): for lmsg in lser.patches: if lmsg is None: continue if lmsg.git_patch_id: q.append(f'patchid:{lmsg.git_patch_id} OR nq:"for patch-id {lmsg.git_patch_id}"') if not q: return query = ' OR '.join(q) qmsgs = get_pi_search_results(query, message='Looking for additional code-review trailers on %s') if not qmsgs: logger.debug('No matching code-review messages') return logger.debug('Retrieved %s code-review messages', len(qmsgs)) # weed out known messages qmid_map = dict() for qmsg in qmsgs: qmid = LoreMessage.get_clean_msgid(qmsg) if qmid in self.msgid_map: logger.debug(' skipping known message: %s', qmid) continue qmid_map[qmid] = LoreMessage(qmsg) for qmid, qlmsg in qmid_map.items(): logger.debug(' new message: %s', qmid) if not qlmsg.reply: logger.debug(' skipping non-reply: %s', qmid) continue if not qlmsg.trailers: logger.debug(' skipping reply without trailers: %s', qmid) continue # Find the patch-id to which this belongs pfound = False for refmid in qlmsg.references: if refmid in qmid_map: # Is it a patch? if not qmid_map[refmid].has_diff: continue pqpid = qmid_map[refmid].git_patch_id if pqpid: # Found our parent patch if pqpid not in self.trailer_map: self.trailer_map[pqpid] = list() self.trailer_map[pqpid] += qlmsg.trailers pfound = True logger.debug(' found matching patch-id for %s', qlmsg.subject) break if not pfound: logger.debug(' no matching parents for %s', qlmsg.subject) # Does it have 'patch-id: ' in the body? # TODO: once we have that functionality in b4 cr def get_series(self, revision: Optional[int] = None, sloppytrailers: bool = False, reroll: bool = True, codereview_trailers: bool = True) -> Optional['LoreSeries']: if revision is None: if not len(self.series): return None # Use the highest revision revision = max(self.series.keys()) elif revision not in self.series.keys(): return None lser = self.series[revision] # Is it empty? empty = True for lmsg in lser.patches: if lmsg is not None: empty = False break if empty: logger.critical('All patches in series v%s are missing.', lser.revision) return None if not lser.complete and reroll: self.partial_reroll(revision, sloppytrailers) # Grab our cover letter if we have one if revision in self.covers: lser.add_patch(self.covers[revision]) lser.has_cover = True else: # Let's find the first patch with an in-reply-to and see if that # is our cover letter for member in lser.patches: if member is not None and member.in_reply_to is not None: potential = self.get_by_msgid(member.in_reply_to) if potential is not None and potential.has_diffstat and not potential.has_diff: # This is *probably* the cover letter lser.patches[0] = potential lser.has_cover = True break if codereview_trailers and can_network: self.load_codereview_trailers() # Do we have any follow-ups? for fmsg in self.followups: logger.debug('Analyzing follow-up: %s (%s)', fmsg.full_subject, fmsg.fromemail) # If there are no trailers in this one, ignore it if not len(fmsg.trailers): logger.debug(' no trailers found, skipping') continue # Go up through the follow-ups and tally up trailers until # we either run out of in-reply-tos, or we find a patch in # one of our series if fmsg.in_reply_to is None: # Check if there's something matching in References pmsg = None for refid in fmsg.references: if refid in self.msgid_map and refid != fmsg.msgid: pmsg = self.msgid_map[refid] break if pmsg is None: # Can't find the message we're replying to here continue elif fmsg.in_reply_to in self.msgid_map: pmsg = self.msgid_map[fmsg.in_reply_to] else: logger.debug(' missing message, skipping: %s', fmsg.in_reply_to) continue trailers, mismatches = fmsg.get_trailers(sloppy=sloppytrailers) 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 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: # add this into our trailer map to carry over trailers from # previous revisions to current revision if patch id did # not change if pmsg.git_patch_id: if pmsg.git_patch_id not in self.trailer_map: self.trailer_map[pmsg.git_patch_id] = list() self.trailer_map[pmsg.git_patch_id] += trailers pmsg.followup_trailers += trailers break if not pmsg.reply: # Could be a cover letter pmsg.followup_trailers += trailers break if pmsg.in_reply_to and pmsg.in_reply_to in self.msgid_map: # Avoid bad message id causing infinite loop if pmsg == self.msgid_map[pmsg.in_reply_to]: break lvl += 1 for pltr in pmsg.trailers: pltr.lmsg = pmsg trailers.append(pltr) pmsg = self.msgid_map[pmsg.in_reply_to] continue break # Carry over trailers from previous series if patch/metadata did not change for lmsg in lser.patches: if lmsg is None or lmsg.git_patch_id is None: continue if lmsg.git_patch_id in self.trailer_map: for fltr in self.trailer_map[lmsg.git_patch_id]: if fltr not in lmsg.trailers and fltr not in lmsg.followup_trailers: # logger.info(' + %s', fltr.as_string()) logger.debug(' adding "%s" from trailer_map to: %s', fltr.as_string(), lmsg.full_subject) lmsg.followup_trailers.append(fltr) return lser def add_message(self, msg: email.message.Message) -> None: msgid = LoreMessage.get_clean_msgid(msg) 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) if msgid: self.msgid_map[lmsg.msgid] = lmsg if lmsg.reply: # We'll figure out where this belongs later logger.debug(' adding to followups') self.followups.append(lmsg) return if lmsg.counter == 0 and (not lmsg.counters_inferred or lmsg.has_diffstat): # Cover letter # Add it to covers -- we'll deal with them later logger.debug(' adding as v%s cover letter', lmsg.revision) self.covers[lmsg.revision] = lmsg return if lmsg.has_diff: if lmsg.revision not in self.series: if lmsg.revision_inferred and lmsg.in_reply_to: # We have an inferred revision here. # Do we have an upthread cover letter that specifies a revision? irt = self.get_by_msgid(lmsg.in_reply_to) if irt is not None and irt.has_diffstat and not irt.has_diff: # Yes, this is very likely our cover letter logger.debug(' fixed revision to v%s', irt.revision) lmsg.revision = irt.revision # alternatively, see if upthread is patch 1 elif lmsg.counter > 0 and irt is not None and irt.has_diff and irt.counter == 1: logger.debug(' fixed revision to v%s', irt.revision) lmsg.revision = irt.revision # Run our check again if lmsg.revision not in self.series: self.series[lmsg.revision] = LoreSeries(lmsg.revision, lmsg.expected) if len(self.series) > 1: 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 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] if (omsg is not None and omsg.counters_inferred and lmsg.fromemail == omsg.fromemail and omsg.date < lmsg.date): lmsg.revision = len(self.series) + 1 self.series[lmsg.revision] = LoreSeries(lmsg.revision, lmsg.expected) logger.info('Assuming new revision: v%s (%s)', lmsg.revision, lmsg.full_subject) logger.debug(' adding as patch') self.series[lmsg.revision].add_patch(lmsg) return logger.debug(' adding to unknowns') self.unknowns.append(lmsg) class LoreSeries: 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.subject = '(untitled)' def __repr__(self): out = list() out.append('- Series: [v%s] %s' % (self.revision, self.subject)) out.append(' revision: %s' % self.revision) 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 for member in self.patches: if member is not None: out.append(' [%s/%s] %s' % (at, self.expected, member.subject)) else: out.append(' [%s/%s] MISSING' % (at, self.expected)) at += 1 return '\n'.join(out) def add_patch(self, lmsg: 'LoreMessage') -> None: while len(self.patches) < lmsg.expected + 1: self.patches.append(None) self.expected = lmsg.expected if self.patches[lmsg.counter] is not None: # Okay, weird, is the one in there a reply? omsg = self.patches[lmsg.counter] logger.warning('WARNING: duplicate messages found at index %s', lmsg.counter) logger.warning(' Subject 1: %s', lmsg.subject) logger.warning(' Subject 2: %s', omsg.subject) if omsg.reply or (omsg.counters_inferred and not lmsg.counters_inferred): # Replace that one with this one logger.warning(' 2 is a reply... replacing existing: %s', omsg.subject) self.patches[lmsg.counter] = lmsg else: logger.warning(' 2 is not a reply... assume additional patch') self.patches.append(None) self.expected = self.expected + 1 self.patches[lmsg.counter] = lmsg self.patches[lmsg.counter + 1] = omsg 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: self.subject = self.patches[0].subject elif self.patches[1] is not None: self.subject = self.patches[1].subject def get_slug(self, extended: bool = False) -> str: # Find the first non-None entry lmsg = None for lmsg in self.patches: if lmsg is not None: break if lmsg is None: return 'undefined' prefix = lmsg.date.strftime('%Y%m%d') authorline = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('from', [])])[0] if extended: local = authorline[1].split('@')[0] unsafe = '%s_%s_%s' % (prefix, local, lmsg.subject) slug = re.sub(r'\W+', '_', unsafe).strip('_').lower() else: author = re.sub(r'\W+', '_', authorline[1]).strip('_').lower() slug = '%s_%s' % (prefix, author) if self.revision != 1: slug = 'v%s_%s' % (self.revision, slug) return slug[:100] 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: bool = False, addmysob: bool = False, addlink: bool = False, cherrypick: Optional[List[int]] = None, copyccs: bool = False, allowbadchars: bool = False) -> List[email.message.Message]: usercfg = get_user_config() config = get_main_config() if addmysob: if 'name' not in usercfg or 'email' not in usercfg: logger.critical('WARNING: Unable to add your Signed-off-by: git returned no user.name or user.email') addmysob = False attpolicy = config['attestation-policy'] try: maxdays = int(config['attestation-staleness-days']) except ValueError: logger.info('WARNING: attestation-staleness-days must be an int') maxdays = 0 # Loop through all patches and see if attestation is the same for all of them, # since it usually is attref = None attsame = True attmark = None attcrit = False if attpolicy != 'off': logger.info('Checking attestation on all messages, may take a moment...') for lmsg in self.patches[1:]: if lmsg is None: attsame = False break checkmark, trailers, attcrit = lmsg.get_attestation_trailers(attpolicy, maxdays) if attref is None: attref = trailers attmark = checkmark continue if set(trailers) == set(attref): continue attsame = False logger.debug('Attestation info is not the same') break if can_network and config.get('pw-url') and config.get('pw-project'): # Use this to pre-load the CI status of each patch logger.info('Retrieving CI status, may take a moment...') show_ci_checks = True # If there is more than one patch in the series, check the last patch first # and skip checking the rest of the patches if the last patch is still 'pending' if self.expected > 1: lastp = self.patches[-1] if lastp: # This will be cached, so we don't worry about extra lookups lastp.load_ci_status() if not lastp.pw_ci_status or lastp.pw_ci_status == 'pending': logger.debug('No CI on the last patch, skipping the rest of the checks') lastp.pw_ci_status = None show_ci_checks = False if show_ci_checks: ci_overall = 'success' series_url = None for lmsg in self.patches[1:]: if lmsg is None: continue lmsg.load_ci_status() if not lmsg.pw_ci_status or lmsg.pw_ci_status == 'pending': lmsg.pw_ci_status = None break if series_url is None: pwdata = lmsg.get_patchwork_info() if pwdata and pwdata.get('series'): for series in pwdata.get('series'): series_url = series.get('web_url') break if lmsg.pw_ci_status == 'warning': ci_overall = 'warning' elif lmsg.pw_ci_status == 'fail': ci_overall = 'fail' if ci_overall != 'success': logger.info('Some CI checks failed, see patchwork for more info:') logger.info(' %s', series_url) self.add_cover_trailers() at = 1 msgs = list() logger.info('---') for lmsg in self.patches[1:]: if cherrypick is not None: if at not in cherrypick: at += 1 logger.debug(' skipped: [%s/%s] (not in cherrypick)', at, self.expected) continue if lmsg is None: logger.critical('CRITICAL: [%s/%s] is missing, cannot cherrypick', at, self.expected) raise KeyError('Cherrypick not in series') if lmsg is not None: extras = list() if addlink: linktrailer = None ltrmask = config.get('linktrailermask') if ltrmask: if ltrmask.find(':'): lparts = ltrmask.split(':', maxsplit=1) llname = lparts[0].strip() llval = lparts[1].strip() % lmsg.msgid linktrailer = LoreTrailer(name=llname, value=llval) else: logger.critical('linktrailermask does not look like a valid trailer, using defaults') if not linktrailer: llval = config.get('linkmask', LOREADDR + '/r/%s') % lmsg.msgid linktrailer = LoreTrailer(name='Link', value=llval) extras.append(linktrailer) if attsame and not attcrit: if attmark: logger.info(' %s %s', attmark, lmsg.get_am_subject()) else: logger.info(' %s', lmsg.get_am_subject()) else: checkmark, trailers, critical = lmsg.get_attestation_trailers(attpolicy, maxdays) if checkmark: logger.info(' %s %s', checkmark, lmsg.get_am_subject()) else: logger.info(' %s', lmsg.get_am_subject()) for trailer in trailers: logger.info(' %s', trailer) if critical: import sys logger.critical('---') logger.critical('Exiting due to attestation-policy: hardfail') sys.exit(128) add_trailers = True if noaddtrailers: add_trailers = False 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) at += 1 if attpolicy == 'off': return msgs if attsame and attref: logger.info(' ---') for trailer in attref: logger.info(' %s', trailer) if not (can_dkim and can_patatt): logger.info(' ---') if not can_dkim: logger.info(' NOTE: install dkimpy for DKIM signature verification') if not can_patatt: logger.info(' NOTE: install patatt for end-to-end signature verification') return msgs def populate_indexes(self): self.indexes = list() seenfiles = set() for lmsg in self.patches[1:]: if lmsg is None or not lmsg.blob_indexes: continue for ofn, obh, nfn, fmod in lmsg.blob_indexes: logger.debug('%s/%s: ofn=%s, obh=%s, nfn=%s, fmod=%s', lmsg.counter, lmsg.expected, ofn, obh, nfn, fmod) if ofn 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 seenfiles.add(ofn) if set(obh) == {'0'}: # New file, will for sure apply clean continue self.indexes.append((ofn, obh)) def check_applies_clean(self, gitdir: Optional[str] = None, 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: ecode, out = git_run_command(gitdir, ['ls-tree', at, fn]) if ecode == 0 and len(out): chunks = out.split() if chunks[2].startswith(bh): logger.debug('%s hash: matched', fn) continue else: logger.debug('%s hash: %s (expected: %s)', fn, chunks[2], bh) else: # Couldn't get this file, continue logger.debug('Could not look up %s:%s', at, fn) mismatches.append((fn, bh)) return len(self.indexes), mismatches 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: if lmsg is None: continue pdate = lmsg.date break # Find the latest commit on that date guntil = pdate.strftime('%Y-%m-%d') if branches: where = branches else: where = ['--all'] gitargs = ['log', '--pretty=oneline', '--until', guntil, '--max-count=1'] + where lines = git_get_command_lines(gitdir, gitargs) if not lines: raise IndexError commit = lines[0].split()[0] checked, mismatches = self.check_applies_clean(gitdir, commit) fewest = len(mismatches) if fewest > 0: since = pdate - datetime.timedelta(days=maxdays) gsince = since.strftime('%Y-%m-%d') logger.debug('Starting --find-object from %s to %s', gsince, guntil) best = commit for fn, bi in mismatches: logger.debug('Finding tree matching %s=%s in %s', fn, bi, where) gitargs = ['log', '--pretty=oneline', '--since', gsince, '--until', guntil, '--find-object', bi] + where lines = git_get_command_lines(gitdir, gitargs) if not lines: logger.debug('Could not find object %s in the tree', bi) continue 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 deletion # Keep track of the fewest mismatches for tc in [commit, f'{commit}~1']: sc, sm = self.check_applies_clean(gitdir, tc) if len(sm) < fewest and len(sm) != sc: fewest = len(sm) best = tc logger.debug('fewest=%s, best=%s', fewest, best) if fewest == 0: break if fewest == 0: break if fewest == 0: break if fewest == 0: break else: best = commit 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 raise IndexError def make_fake_am_range(self, gitdir: Optional[str], at_base: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: start_commit = end_commit = None # Use the msgid of the first non-None patch in the series msgid = None for lmsg in self.patches: if lmsg is not None: msgid = lmsg.msgid break if msgid is None: logger.critical('Cannot operate on an empty series') return None, None cachedata = get_cache(msgid, suffix='fakeam') if cachedata and not self.partial_reroll: stalecache = False chunks = cachedata.strip().split() if len(chunks) == 2: start_commit, end_commit = chunks else: stalecache = True if start_commit is not None and end_commit is not None: # Make sure they are still there if git_commit_exists(gitdir, start_commit) and git_commit_exists(gitdir, end_commit): logger.debug('Using previously generated range') return start_commit, end_commit stalecache = True if stalecache: logger.debug('Stale cache for [v%s] %s', self.revision, self.subject) clear_cache(msgid, suffix='fakeam') logger.info('Preparing fake-am for v%s: %s', self.revision, self.subject) if not at_base: if self.base_commit and git_commit_exists(gitdir, self.base_commit): at_base = self.base_commit else: try: at_base = self.find_base(gitdir)[0] except IndexError: pass topdir = git_get_toplevel(gitdir) with git_temp_worktree(topdir, at_base): # Logic largely borrowed from gj_tools msgs = list() seenfiles = set() for lmsg in self.patches[1:]: if lmsg is None: logger.critical('ERROR: v%s series incomplete; unable to create a fake-am range', self.revision) return None, None logger.debug('Looking at %s', lmsg.full_subject) if not lmsg.blob_indexes: logger.critical('ERROR: some patches do not have indexes') logger.critical(' unable to create a fake-am range') return None, None for ofn, ofi, nfn, fmod in lmsg.blob_indexes: logger.debug('ofn=%s, ofi=%s, nfn=%s, fmod=%s', ofn, ofi, nfn, fmod) if ofn in seenfiles: logger.debug('ofn=%s already seen, skipping', ofn) # We already processed this file, so this blob won't match continue seenfiles.add(ofn) if set(ofi) == {'0'}: # New file creation, nothing to do here logger.debug(' New file: %s', ofn) continue if not ofn == nfn: # renamed file, make sure to not add the new name later on logger.debug(' Renamed file: %s -> %s', ofn, nfn) seenfiles.add(nfn) # Try to grab full ref_id of this hash try: ohash = git_revparse_obj(ofi) logger.debug(' Found matching blob for: %s', ofn) gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{ohash},{ofn}'] except RuntimeError: logger.debug('Could not find matching blob for %s (%s)', ofn, ofi) try: chash = git_revparse_obj(f':{ofn}', topdir) gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{chash},{ofn}'] except RuntimeError: logger.critical(' ERROR: Could not find anything matching %s', ofn) return None, None ecode, out = git_run_command(None, gitargs) if ecode > 0: logger.critical(' ERROR: Could not run update-index for %s (%s)', ofn, ohash) return None, None msgs.append(lmsg.get_am_message(add_trailers=False)) ecode, out = git_run_command(None, ['write-tree']) if ecode > 0: logger.critical('ERROR: Could not write fake-am tree') return None, None treeid = out.strip() # At this point we have a worktree with files that should (hopefully) cleanly receive a git am gitargs = ['commit-tree', treeid + '^{tree}', '-F', '-'] ecode, out = git_run_command(None, gitargs, stdin='Initial fake commit'.encode('utf-8')) if ecode > 0: logger.critical('ERROR: Could not commit-tree') return None, None start_commit = out.strip() logger.debug('start_commit=%s', start_commit) git_run_command(None, ['reset', '--hard', start_commit]) ifh = io.BytesIO() save_git_am_mbox(msgs, ifh) ambytes = ifh.getvalue() ecode, out = git_run_command(None, ['am'], stdin=ambytes, logstderr=True) if ecode > 0: logger.critical('ERROR: Could not fake-am version v%s', self.revision) return None, None ecode, out = git_run_command(None, ['rev-parse', 'HEAD']) end_commit = out.strip() logger.info(' range: %.12s..%.12s', start_commit, end_commit) logger.debug('Saving into cache:') logger.debug(' %s..%s', start_commit, end_commit) save_cache(f'{start_commit} {end_commit}\n', msgid, suffix='fakeam') return start_commit, end_commit def save_cover(self, outfile): # noinspection PyUnresolvedReferences cover_msg = self.patches[0].get_am_message(add_trailers=False) with open(outfile, 'wb') as fh: fh.write(LoreMessage.get_msg_as_bytes(cover_msg, headers='decode')) 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', 'closes', 'obsoleted-by', 'message-id', 'change-id', 'base-commit'} 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' self.type = 'person' ucfg = get_user_config() try: self.value = '%s <%s>' % (ucfg['name'], ucfg['email']) self.addr = (ucfg['name'], ucfg['email']) except KeyError: self.value = 'User Not Set ' self.addr = ('User Not Set', 'email@not.set') else: self.name = name self.value = value if name.lower() in self._utility or '://' in value: self.type = 'utility' elif re.search(r'\S+@\S+\.\S+', value): self.type = 'person' self.addr = email.utils.parseaddr(value) # Normalize the value with parsed data if self.addr[0]: self.value = f'{self.addr[0]} <{self.addr[1]}>' else: self.value = self.addr[1] 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: 'LoreTrailer') -> bool: # 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) -> hash: return hash(f'{self.lname}: {self.value}') def __repr__(self) -> str: 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: msg: email.message.Message msgid: str # Subject-based info lsubject: 'LoreSubject' full_subject: str subject: str reply: bool revision: int reroll_from_revision: Optional[int] counter: int expected: int revision_inferred: bool counters_inferred: bool # header-based info in_reply_to: Optional[str] references: Set[str] fromname: str fromemail: str date: datetime.datetime # body-based info body: str message: str charset: str has_diff: bool has_diffstat: bool trailers: List['LoreTrailer'] followup_trailers: List['LoreTrailer'] # populated by pr pr_base_commit: Optional[str] pr_repo: Optional[str] pr_ref: Optional[str] pr_tip_commit: Optional[str] pr_remote_tip_commit: Optional[str] # patch-related info pw_ci_status: Optional[str] def __init__(self, msg): self.msg = msg # set some defaults self.revision = 1 self.reroll_from_revision = None self.counter = 1 self.expected = 1 self.revision_inferred = True self.counters_inferred = True self.charset = 'utf-8' self.has_diff = False self.has_diffstat = False self.trailers = list() self.followup_trailers = list() self.pr_base_commit = None self.pr_repo = None self.pr_ref = None self.pr_tip_commit = None self.pr_remote_tip_commit = None self.pw_ci_status = None self.msgid = LoreMessage.get_clean_msgid(self.msg) self.lsubject = LoreSubject(msg['Subject']) # Copy them into this object for convenience self.full_subject = self.lsubject.full_subject self.subject = self.lsubject.subject self.reply = self.lsubject.reply self.revision = self.lsubject.revision self.counter = self.lsubject.counter self.expected = self.lsubject.expected self.revision_inferred = self.lsubject.revision_inferred self.counters_inferred = self.lsubject.counters_inferred # Loaded when properties are called self._attestors = None self._git_patch_id = None self._pwhash = None self._blob_indexes = None # Handle [PATCH 6/5] if self.counter > self.expected: self.expected = self.counter self.in_reply_to = LoreMessage.get_clean_msgid(self.msg, header='In-Reply-To') self.references = set() if self.in_reply_to: self.references.add(self.in_reply_to) if self.msg.get('References'): for pair in email.utils.getaddresses([str(x) for x in self.msg.get_all('references', [])]): if pair: self.references.add(pair[1]) try: fromdata = email.utils.getaddresses([LoreMessage.clean_header(str(x)) for x in self.msg.get_all('from', [])])[0] self.fromname = fromdata[0] self.fromemail = fromdata[1] if not len(self.fromname.strip()): self.fromname = self.fromemail except IndexError: pass msgdate = self.msg.get('Date') if msgdate: self.date = email.utils.parsedate_to_datetime(str(msgdate)) else: # An email without a Date: field? self.date = datetime.datetime.now() # Force it to UTC if it's naive if self.date.tzinfo is None: self.date = self.date.replace(tzinfo=datetime.timezone.utc) # walk until we find the first text/plain part self.body, self.charset = LoreMessage.get_payload(self.msg) if self.body is None: # Woah, we didn't find any usable parts logger.debug(' No plain or patch parts found in message') logger.info(' Not plaintext: %s', self.full_subject) return if DIFFSTAT_RE.search(self.body): self.has_diffstat = True if DIFF_RE.search(self.body): self.has_diff = True trailers, others = LoreMessage.find_trailers(self.body, followup=True) # We only pay attention to trailers that are sent in reply if trailers and self.in_reply_to and not self.has_diff and not self.reply: logger.debug('A follow-up missing a Re: but containing a trailer with no patch diff') self.reply = True if self.reply: for trailer in trailers: # These are commonly part of patch/commit metadata badtrailers = {'from', 'author', 'cc', 'to', 'date', 'subject', 'subscribe', 'unsubscribe'} if trailer.lname not in badtrailers: trailer.lmsg = self self.trailers.append(trailer) def get_trailers(self, sloppy: bool = False) -> Tuple[List[LoreTrailer], Set[LoreTrailer]]: trailers = list() mismatches = set() 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 # Does the name match, at least? nmatch = False tlname = ltr.addr[0].lower() hlname = self.fromname.lower() if tlname == hlname: logger.debug(' trailer exact name match') 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: nmatch = True for nchunk in hlname.split(','): if hlname.find(nchunk.strip()) < 0: nmatch = False break 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 @property def git_patch_id(self) -> str: if self._git_patch_id is None and self.has_diff: self._git_patch_id = LoreMessage.get_patch_id(self.body) return self._git_patch_id @property def pwhash(self) -> str: if self._pwhash is None and self.has_diff: self._pwhash = LoreMessage.get_patchwork_hash(self.body) return self._pwhash @property def blob_indexes(self) -> Set[Tuple[str, str, str, str]]: if self._blob_indexes is None: if self.has_diff: self._blob_indexes = LoreMessage.get_indexes(self.body) else: self._blob_indexes = set() return self._blob_indexes @property def attestors(self) -> List['LoreAttestor']: if self._attestors is not None: return self._attestors self._attestors = list() config = get_main_config() if config['attestation-policy'] == 'off': return self._attestors logger.debug('Loading attestation: %s', self.full_subject) if self.msg.get(DEVSIG_HDR): self._load_patatt_attestors() if self.msg.get('dkim-signature') and config['attestation-check-dkim'] == 'yes': self._load_dkim_attestors() logger.debug('Attestors: %s', len(self._attestors)) return self._attestors def _load_dkim_attestors(self) -> None: if not can_network: logger.debug('Message has DKIM signatures, but can_network is off') return if not can_dkim: logger.debug('Message has DKIM signatures, but can_dkim is off') return # Yank out all DKIM-Signature headers and try them in reverse order # until we come to a passing one dkhdrs = list() for header in list(self.msg._headers): # noqa if header[0].lower() == 'dkim-signature': dkhdrs.append(header) self.msg._headers.remove(header) # noqa dkhdrs.reverse() seenatts = list() for hn, hval in dkhdrs: # Handle MIME encoded-word syntax or other types of header encoding if # present. if '?q?' in hval: hval = str(email.header.make_header(email.header.decode_header(hval))) errors = list() hdata = LoreMessage.get_parts_from_header(hval) logger.debug('Loading DKIM attestation for d=%s, s=%s', hdata['d'], hdata['s']) identity = hdata.get('i') if not identity: identity = hdata.get('d') ts = hdata.get('t') signtime = None if ts: signtime = LoreAttestor.parse_ts(ts) else: # See if date is included in the h: field sh = hdata.get('h') if 'date' in sh.lower().split(':'): signtime = self.date self.msg._headers.append((hn, hval)) # noqa try: res = dkim.verify(self.msg.as_bytes(policy=emlpolicy), logger=dkimlogger) logger.debug('DKIM verify results: %s=%s', identity, res) except Exception as ex: # noqa # Usually, this is due to some DNS resolver failure, which we can't # possibly cleanly try/catch. Just mark it as failed and move on. logger.debug('DKIM attestation failed: %s', ex) errors.append(str(ex)) res = False attestor = LoreAttestorDKIM(res, identity, signtime, errors) if attestor.check_identity(self.fromemail): # use this one, regardless of any other DKIM signatures self._attestors.append(attestor) return self.msg._headers.pop(-1) # noqa seenatts.append(attestor) # No exact domain matches, so return everything we have self._attestors += seenatts def _trim_body(self) -> None: # Get the length specified in the X-Developer-Signature header xdsh = self.msg.get('X-Developer-Signature') if not xdsh: return matches = re.search(r'\s+l=(\d+)', xdsh) if not matches: return bl = int(matches.groups()[0]) i, m, p = get_mailinfo(self.msg.as_bytes(policy=emlpolicy), scissors=False) bb = b'' for line in re.sub(rb'[\r\n]*$', b'', m + p).split(b'\n'): bb += re.sub(rb'[\r\n]*$', b'', line) + b'\r\n' if len(bb) > bl: self.body = bb[:bl].decode() # This may have potentially resulted in in-body From/Subject being removed, # so make sure we put them back into the body if they are different ibh = list() if i.get('Subject') != self.subject: ibh.append('Subject: ' + i.get('Subject')) if i.get('Email') != self.fromemail or i.get('Author') != self.fromname: ibh.append('From: ' + format_addrs([(i.get('Author'), i.get('Email'))])) if len(ibh): self.body = '\n'.join(ibh) + '\n\n' + self.body def _load_patatt_attestors(self) -> None: if not can_patatt: logger.debug('Message has %s headers, but can_patatt is off', DEVSIG_HDR) return # load our key sources if necessary ddir = get_data_dir() pdir = os.path.join(ddir, 'keyring') config = get_main_config() sources = config.get('keyringsrc') if not sources: # 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) # Push our logger and GPGBIN into patatt patatt.logger = logger patatt.GPGBIN = config['gpgbin'] logger.debug('Loading patatt attestations with sources=%s', str(sources)) success = False trim_body = False while True: attestations = patatt.validate_message(self.msg.as_bytes(policy=emlpolicy), sources, trim_body=trim_body) # Do we have any successes? for attestation in attestations: if attestation[0] == patatt.RES_VALID: success = True break if success: if trim_body: # If we only succeeded after trimming the body, then we MUST set the body # to that value, otherwise someone can append arbitrary content after the l= value # limit message. self._trim_body() break if not success and trim_body: break trim_body = True for result, identity, signtime, keysrc, keyalgo, errors in attestations: if keysrc and keysrc.startswith('(default keyring)/'): fpr = keysrc.split('/', 1)[1] uids = get_gpg_uids(fpr) idmatch = False for uid in uids: if uid.find(identity) >= 0: idmatch = True break if not idmatch: # Take the first identity in the list and use that instead parts = email.utils.parseaddr(uids[0]) identity = parts[1] if signtime: signdt = LoreAttestor.parse_ts(signtime) else: signdt = None attestor = LoreAttestorPatatt(result, identity, signdt, keysrc, keyalgo, errors) self._attestors.append(attestor) @staticmethod def get_patchwork_data_by_msgid(msgid: str) -> dict: 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 requires pw-key, pw-url and pw-project settings') raise LookupError('Error looking up %s in patchwork' % msgid) cachedata = get_cache(pwurl + pwproj + msgid, suffix='lookup') if cachedata: import json return json.loads(cachedata) pses, url = get_patchwork_session(pwkey, pwurl) patches_url = '/'.join((url, 'patches')) params = [ ('project', pwproj), ('archived', 'false'), ('msgid', msgid), ] pwdata = None 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: # cache this one pwdata = entry break rsp.close() except requests.exceptions.RequestException as ex: logger.debug('Patchwork REST error: %s', ex) raise LookupError('Error looking up %s in patchwork' % msgid) if not pwdata: logger.debug('Not able to look up patchwork data for %s', msgid) raise LookupError('Error looking up %s in patchwork' % msgid) import json save_cache(json.dumps(pwdata), pwurl + pwproj + msgid, suffix='lookup') return pwdata def get_patchwork_info(self) -> Optional[dict]: if not self.pwhash: return None try: return LoreMessage.get_patchwork_data_by_msgid(self.msgid) except LookupError: return None def get_ci_checks(self) -> list: checks = list() if not self.pw_ci_status or self.pw_ci_status == 'pending': return checks config = get_main_config() pwkey = config.get('pw-key') pwurl = config.get('pw-url') pwdata = self.get_patchwork_info() pw_patch_id = pwdata.get('id') cacheid = pwurl + str(pw_patch_id) + 'checks' cachedata = get_cache(cacheid, suffix='lookup') if cachedata: import json checks = json.loads(cachedata) else: pses, url = get_patchwork_session(pwkey, pwurl) checks_url = '/'.join((url, 'patches', str(pw_patch_id), 'checks')) try: logger.debug('looking up checks for patch_id=%s', pw_patch_id) rsp = pses.get(checks_url, stream=False) rsp.raise_for_status() pdata = rsp.json() for entry in pdata: if entry.get('state') == 'success' or entry.get('state') not in CI_FLAGS_FANCY: # We don't care to see these ;) continue checks.append((entry.get('state'), entry.get('context'), entry.get('description'))) rsp.close() except requests.exceptions.RequestException as ex: logger.debug('Patchwork REST error: %s', ex) return checks if not pwdata: logger.debug('Not able to look up patchwork checks data for %s', self.msgid) return checks import json save_cache(json.dumps(checks), cacheid, suffix='lookup') return checks def load_ci_status(self) -> None: logger.debug('Loading CI status for %s', self.msgid) pwdata = self.get_patchwork_info() if not pwdata: return ci_status = pwdata.get('check', 'pending') if ci_status not in CI_FLAGS_FANCY: logger.debug(' unknown CI status: ci_status') logger.debug('ci_state for %s: %s', self.msgid, ci_status) self.pw_ci_status = ci_status def get_attestation_trailers(self, attpolicy: str, maxdays: int = 0) -> Tuple[str, list, bool]: trailers = list() checkmark = None critical = False for attestor in self.attestors: if attestor.passing and maxdays and not attestor.check_time_drift(self.date, maxdays): logger.debug('The time drift is too much, marking as non-passing') attestor.passing = False if not attestor.passing: # Is it a person-trailer for which we have a key? if attestor.level == 'person': if attestor.have_key: # This was signed, and we have a key, but it's failing trailers.append('%s BADSIG: %s' % (attestor.checkmark, attestor.trailer)) checkmark = attestor.checkmark elif attpolicy in ('softfail', 'hardfail'): trailers.append('%s No key: %s' % (attestor.checkmark, attestor.trailer)) # This is not critical even in hardfail continue elif attpolicy in ('softfail', 'hardfail'): if not checkmark: checkmark = attestor.checkmark trailers.append('%s BADSIG: %s' % (attestor.checkmark, attestor.trailer)) 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, self.fromemail)) return checkmark, trailers, critical def __repr__(self): out = list() out.append('msgid: %s' % self.msgid) out.append(str(self.lsubject)) out.append(' fromname: %s' % self.fromname) out.append(' fromemail: %s' % self.fromemail) out.append(' date: %s' % str(self.date)) out.append(' in_reply_to: %s' % self.in_reply_to) # Header-based info out.append(' --- begin body ---') for line in self.body.split('\n'): out.append(' |%s' % line) out.append(' --- end body ---') # Body and body-based info out.append(' has_diff: %s' % self.has_diff) out.append(' has_diffstat: %s' % self.has_diffstat) out.append(' --- begin my trailers ---') for trailer in self.trailers: out.append(' |%s' % str(trailer)) out.append(' --- begin followup trailers ---') for trailer in self.followup_trailers: out.append(' |%s' % str(trailer)) out.append(' --- end trailers ---') out.append(' --- begin attestors ---') for attestor in self.attestors: out.append(' |%s' % str(attestor)) out.append(' --- end attestors ---') return '\n'.join(out) @staticmethod def get_payload(msg: email.message.Message, use_patch: bool = True) -> Tuple[str, str]: # walk until we find the first text/plain part mcharset = msg.get_content_charset() if not mcharset: mcharset = 'utf-8' mbody = None for part in msg.walk(): cte = part.get_content_type() if cte.find('/plain') < 0 and cte.find('/x-patch') < 0: continue payload = part.get_payload(decode=True) if payload is None: continue pcharset = part.get_content_charset() if not pcharset: pcharset = mcharset try: payload = payload.decode(pcharset, errors='replace') mcharset = pcharset except LookupError: # what kind of encoding is that? # Whatever, we'll use utf-8 and hope for the best payload = payload.decode('utf-8', errors='replace') part.set_param('charset', 'utf-8') mcharset = 'utf-8' if mbody is None: mbody = payload continue # If we already found a body, but we now find something that contains a diff, # then we prefer this part if use_patch and DIFF_RE.search(payload): mbody = payload return mbody, mcharset @staticmethod def clean_header(hdrval: Optional[str]) -> str: if hdrval is None: return '' if hdrval.find('=?') >= 0: # Do we have any email addresses in there? if re.search(r'<\S+@\S+>', hdrval, flags=re.I | re.M): newaddrs = list() for addr in email.utils.getaddresses([hdrval]): if addr[0].find('=?') >= 0: # Nothing wrong with nested calls, right? addr = (LoreMessage.clean_header(addr[0]), addr[1]) # Work around https://github.com/python/cpython/issues/100900 if re.search(r'[^\w\s]', addr[0]): newaddrs.append(f'"{addr[0]}" <{addr[1]}>') else: newaddrs.append(email.utils.formataddr(addr)) return ', '.join(newaddrs) 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-8 decoded += hstr.decode('utf-8', errors='replace') except (UnicodeDecodeError, AttributeError): decoded += hstr else: decoded = hdrval new_hdrval = re.sub(r'\n?\s+', ' ', decoded) return new_hdrval.strip() @staticmethod def wrap_header(hdr, width: int = 75, nl: str = '\n', transform: Literal['encode', 'decode', 'preserve'] = 'preserve') -> bytes: hname, hval = hdr if hname.lower() in ('to', 'cc', 'from', 'x-original-from'): _parts = [f'{hname}: '] first = True for addr in email.utils.getaddresses([hval]): if transform == 'encode' and not addr[0].isascii(): addr = (email.quoprimime.header_encode(addr[0].encode(), charset='utf-8'), addr[1]) qp = format_addrs([addr], clean=False) elif transform == 'decode': qp = format_addrs([addr], clean=True) else: qp = format_addrs([addr], clean=False) # See if there is enough room on the existing line if first: _parts[-1] += qp first = False continue if len(_parts[-1] + ', ' + qp) > width: _parts[-1] += ', ' _parts.append(qp) continue _parts[-1] += ', ' + qp else: if transform == 'decode' and hval.find('?=') >= 0: hdata = f'{hname}: ' + LoreMessage.clean_header(hval) else: hdata = f'{hname}: {hval}' if transform != 'encode' or hval.isascii(): if len(hdata) <= width: return hdata.encode() # Use simple textwrap, with a small trick that ensures that long non-breakable # strings don't show up on the next line from the bare header hdata = hdata.replace(': ', ':_', 1) wrapped = textwrap.wrap(hdata, break_long_words=False, break_on_hyphens=False, subsequent_indent=' ', width=width) return nl.join(wrapped).replace(':_', ': ', 1).encode() qp = f'{hname}: ' + email.quoprimime.header_encode(hval.encode(), charset='utf-8') # is it longer than width? if len(qp) <= width: return qp.encode() _parts = list() while len(qp) > width: wrapat = width - 2 if len(_parts): # Also allow for the ' ' at the front on continuation lines wrapat -= 1 # Make sure we don't break on a =XX escape sequence while '=' in qp[wrapat - 2:wrapat]: wrapat -= 1 _parts.append(qp[:wrapat] + '?=') qp = ('=?utf-8?q?' + qp[wrapat:]) _parts.append(qp) return f'{nl} '.join(_parts).encode() @staticmethod def get_msg_as_bytes(msg: email.message.Message, nl: str = '\n', headers: Literal['encode', 'decode', 'preserve'] = 'preserve') -> bytes: bdata = b'' for hname, hval in msg.items(): bdata += LoreMessage.wrap_header((hname, str(hval)), nl=nl, transform=headers) + nl.encode() bdata += nl.encode() payload = msg.get_payload(decode=True) for bline in payload.split(b'\n'): bdata += re.sub(rb'[\r\n]*$', b'', bline) + nl.encode() return bdata @staticmethod def get_parts_from_header(hstr: str) -> dict: hstr = re.sub(r'\s*', '', hstr) hdata = dict() for chunk in hstr.split(';'): parts = chunk.split('=', 1) if len(parts) < 2: continue hdata[parts[0]] = parts[1] return hdata @staticmethod def get_clean_msgid(msg: email.message.Message, header: str = 'Message-Id') -> str: msgid = None raw = msg.get(header) if raw: matches = re.search(r'<([^>]+)>', LoreMessage.clean_header(raw)) if matches: msgid = matches.groups()[0] return msgid @staticmethod def get_preferred_duplicate(msg1: email.message.Message, msg2: email.message.Message) -> email.message.Message: config = get_main_config() listid1 = LoreMessage.get_clean_msgid(msg1, 'list-id') if listid1: prefidx1 = 0 for listglob in config['listid-preference']: if fnmatch.fnmatch(listid1, listglob): break prefidx1 += 1 else: prefidx1 = config['listid-preference'].index('*') listid2 = LoreMessage.get_clean_msgid(msg2, 'list-id') if listid2: prefidx2 = 0 for listglob in config['listid-preference']: if fnmatch.fnmatch(listid2, listglob): break prefidx2 += 1 else: prefidx2 = config['listid-preference'].index('*') if prefidx1 <= prefidx2: logger.debug('Picked duplicate from preferred source: %s', listid1) return msg1 logger.debug('Picked duplicate from preferred source: %s', listid2) 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.""" prefixes = ['-', '+', ' '] hashed = hashlib.sha1() for line in diff.split('\n'): if len(line) <= 0: continue hunk_match = HUNK_RE.match(line) filename_match = FILENAME_RE.match(line) if filename_match: # normalise -p1 top-directories if filename_match.group(1) == '---': filename = 'a/' else: filename = 'b/' filename += '/'.join(filename_match.group(2).split('/')[1:]) line = filename_match.group(1) + ' ' + filename elif hunk_match: # remove line numbers, but leave line counts def fn(x): if not x: return 1 return int(x) line_nos = list(map(fn, hunk_match.groups())) line = '@@ -%d +%d @@' % tuple(line_nos) elif line[0] in prefixes: # if we have a +, - or context line, leave as-is pass else: # other lines are ignored continue hashed.update((line + '\n').encode('utf-8')) return hashed.hexdigest() @staticmethod def get_indexes(diff: str) -> Set[Tuple[str, str, str, str]]: indexes = set() oldfile = None newfile = None fmod = None for line in diff.split('\n'): if not (line.startswith('diff ') or line.startswith('index ') or line.startswith('new file mode ')): continue matches = re.search(r'^diff\s+--git\s+\w/(.*)\s+\w/(.*)$', line) if matches: oldfile = matches.groups()[0] newfile = matches.groups()[1] continue matches = re.search(r'^new file mode (\d+)', line) if matches: fmod = matches.groups()[0] matches = re.search(r'^index\s+([\da-f]+)\.\.[\da-f]+.*$', line) if matches and oldfile is not None and newfile is not None: ohash = matches.groups()[0] if not fmod: matches = re.search(r'^index\s+[\da-f]+\.\.[\da-f]+\s+(\d+)$', line) if matches: fmod = matches.groups()[0] if not fmod: # fall back if we can't figure it out fmod = '10644' indexes.add((oldfile, ohash, newfile, fmod)) return indexes @staticmethod def find_trailers(body: str, followup: bool = False) -> Tuple[List[LoreTrailer], List[str]]: ignores = {'phone', 'email'} headers = {'subject', 'date', 'from'} links = {'link', 'buglink', 'closes'} nonperson = links | {'fixes', 'subject', 'date', 'obsoleted-by', 'change-id', 'base-commit'} # Ignore everything below standard email signature marker body = body.split('\n-- \n', 1)[0].strip() + '\n' # Fix some more common copypasta trailer wrapping # Fixes: abcd0123 (foo bar # baz quux) body = re.sub(r'^(\S+:\s+[\da-f]+\s+\([^)]+)\n([^\n]+\))', r'\1 \2', body, flags=re.M) # Signed-off-by: Long Name # body = re.sub(r'^(\S+:\s+[^<]+)\n(<[^>]+>)$', r'\1 \2', body, flags=re.M) # Signed-off-by: Foo foo # [for the thing that the thing is too long the thing that is # thing but thing] # (too false-positivey, commented out) # body = re.sub(r'^(\[[^]]+)\n([^]]+]$)', r'\1 \2', body, flags=re.M) trailers = list() others = list() was_trailer = False at = 0 for line in body.split('\n'): at += 1 line = line.strip('\r') matches = re.search(r'^\s*(\w\S+):\s+(\S.*)', line, flags=re.I) if matches: oname, ovalue = list(matches.groups()) # We only accept headers if we haven't seen any non-trailer lines lname = oname.lower() if lname in ignores: logger.debug('Ignoring %d: %s (known non-trailer)', at, line) continue if len(others) and lname in headers: logger.debug('Ignoring %d: %s (header after other content)', at, line) continue if followup: if not lname.isascii(): logger.debug('Ignoring %d: %s (known non-ascii follow-up trailer)', at, lname) continue mperson = re.search(r'\S+@\S+\.\S+', ovalue) if not mperson and lname not in nonperson: logger.debug('Ignoring %d: %s (not a recognized non-person trailer)', at, line) continue mlink = re.search(r'https?://', ovalue) if mlink and lname not in links: logger.debug('Ignoring %d: %s (not a recognized link trailer)', at, line) continue extinfo = None mextinfo = re.search(r'(.*\S+)(\s+#[^#]+)$', ovalue) if mextinfo: logger.debug('Trailer contains hashtag extinfo: %d: %s', at, line) # Found extinfo of the hashtag genre egr = mextinfo.groups() ovalue = egr[0] extinfo = egr[1] was_trailer = True 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 # [for the foo bits] 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 others.append(line) return trailers, others @staticmethod def rebuild_message(headers: List[LoreTrailer], message: str, trailers: List[LoreTrailer], basement: str, signature: str) -> str: body = '' if headers: for ltr in headers: # There is no [extdata] in git headers, so we omit it body += ltr.as_string(omit_extinfo=True) + '\n' body += '\n' if len(message): body += message.rstrip('\r\n') + '\n' if len(trailers): body += '\n' for ltr in trailers: body += ltr.as_string() + '\n' if len(basement): if not len(trailers): body += '\n' if DIFFSTAT_RE.search(basement): body += '---\n' else: # If we don't have a diffstat, then we don't need to add a --- body += '\n' body += basement.strip('\r\n') + '\n' if len(signature): body += '-- \n' body += signature.rstrip('\r\n') + '\n' return body @staticmethod 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') # Extra git-relevant headers, like From:, Subject:, Date:, etc githeaders = list() # commit message message = '' # everything below the --- basement = '' # conformant signature --\s\n signature = '' sparts = body.rsplit('\n-- \n', 1) if len(sparts) > 1: signature = sparts[1] body = sparts[0].rstrip('\n') parts = re.split('^---\n', body, maxsplit=1, flags=re.M) if len(parts) == 2: basement = parts[1].rstrip('\n') elif body.find('\ndiff ') >= 0: parts = body.split('\ndiff ', 1) if len(parts) == 2: parts[1] = 'diff ' + parts[1] basement = parts[1].rstrip('\n') elif body.find('\n--- a/') >= 0: # patches generated by some really peculiar tools parts = body.split('\n--- a/', 1) if len(parts) == 2: parts[1] = '--- a/' + parts[1] basement = parts[1].rstrip('\n') mbody = parts[0].strip('\n') # Split into paragraphs bpara = mbody.split('\n\n') # Is every line of the first part in a header format? mparts = list() h, o = LoreMessage.find_trailers(bpara[0]) if len(o): # Not everything was a header, so we don't treat it as headers mparts.append(bpara[0]) else: githeaders = h # Any lines of the last part match the header format? trailers, nlines = LoreMessage.find_trailers(bpara[-1]) if len(bpara) == 1: if githeaders == trailers: # This is a message that consists of just trailers? githeaders = list() if nlines: message = '\n'.join(nlines) return githeaders, message, trailers, basement, signature # Add all parts between first and last to mparts if len(bpara) > 2: mparts += bpara[1:-1] if len(nlines): # Add them as the last part mparts.append('\n'.join(nlines)) message = '\n\n'.join(mparts) return githeaders, message, trailers, basement, signature def fix_trailers(self, extras: Optional[List[LoreTrailer]] = None, copyccs: bool = False, addmysob: bool = False, fallback_order: str = '*', omit_trailers: Optional[List[str]] = None) -> None: config = get_main_config() bheaders, message, btrailers, basement, signature = LoreMessage.get_body_parts(self.body) 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', [])]) alldests += email.utils.getaddresses([str(x) for x in self.msg.get_all('cc', [])]) # Sort by domain name, then local 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 fltr in btrailers + new_trailers: if fltr.email_eq(pair[1]): # already present found = True break if not found: if len(pair[0]): altr = LoreTrailer(name='Cc', value=f'{pair[0]} <{pair[1]}>') else: altr = LoreTrailer(name='Cc', value=pair[1]) new_trailers.append(altr) torder = config.get('trailer-order', fallback_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) 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 attpolicy = config['attestation-policy'] fixtrailers = btrailers # load trailers we should ignore ignore_from = config.get('trailers-ignore-from') if ignore_from: ignores = [x[1].lower() for x in email.utils.getaddresses([ignore_from])] else: ignores = list() ignored = set() for ltr in new_trailers: if ltr in fixtrailers or ltr in ignored: continue if (ltr.addr and ltr.addr[1].lower() in ignores) or (ltr.lmsg and ltr.lmsg.fromemail.lower() in ignores): logger.info(' x %s', ltr.as_string(omit_extinfo=True)) ignored.add(ltr) 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) elif extras is not None and ltr in extras: 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)) if omit_trailers and fixtrailers: for ltr in fixtrailers: if ltr.lname in omit_trailers: fixtrailers.remove(ltr) # Build the new commit message in case we're working directly # on the tree. self.message = self.subject + '\n\n' if len(message): self.message += message.rstrip('\r\n') + '\n' if len(fixtrailers): self.message += '\n' if len(fixtrailers): for ltr in fixtrailers: self.message += ltr.as_string() + '\n' # Split the basement along '---', in case there is extra info in the # message of the commit (used by devs to keep extra info about the patch) bparts = re.split(r'^---\n', basement, flags=re.M) for bpart in list(bparts): # If it's a diff or diffstat, we don't care to keep it if DIFF_RE.search(bpart) or DIFFSTAT_RE.search(bpart): bparts.remove(bpart) if bparts: self.message += '---\n' + '---\n'.join(bparts) self.body = LoreMessage.rebuild_message(bheaders, message, fixtrailers, basement, signature) def get_am_subject(self, indicate_reroll: bool = True, use_subject: Optional[str] = None, show_ci_status: bool = True) -> str: # Return a clean patch subject parts = ['PATCH'] if self.lsubject.rfc: parts.append('RFC') if self.reroll_from_revision: if indicate_reroll: if self.reroll_from_revision != self.revision: parts.append('v%d->v%d' % (self.reroll_from_revision, self.revision)) else: parts.append(' %s v%d' % (' ' * len(str(self.reroll_from_revision)), self.revision)) else: parts.append('v%d' % self.revision) elif not self.revision_inferred: parts.append('v%d' % self.revision) if not self.lsubject.counters_inferred: parts.append('%d/%d' % (self.lsubject.counter, self.lsubject.expected)) if not use_subject: use_subject = self.lsubject.subject if show_ci_status and self.pw_ci_status: return '[%s %s] %s' % (CI_FLAGS_FANCY[self.pw_ci_status], ' '.join(parts), use_subject) return '[%s] %s' % (' '.join(parts), use_subject) def get_am_message(self, add_trailers: bool = True, addmysob: bool = False, extras: Optional[List['LoreTrailer']] = None, copyccs: bool = False, allowbadchars: bool = False) -> email.message.EmailMessage: # 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') != self.body.encode(): 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 # Remove anything that's cut off by scissors mi_msg = email.message.EmailMessage() mi_msg['From'] = LoreMessage.clean_header(self.msg['From']) mi_msg['Date'] = LoreMessage.clean_header(self.msg['Date']) mi_msg['Subject'] = LoreMessage.clean_header(self.msg['Subject']) mi_msg.set_payload(self.body, charset='utf-8') mi_msg.set_charset('utf-8') ifh = io.BytesIO() save_mboxrd_mbox([mi_msg], ifh, mangle_from=True) i, m, p = get_mailinfo(ifh.getvalue(), scissors=True) self.body = m.decode() + p.decode() if add_trailers: self.fix_trailers(copyccs=copyccs, addmysob=addmysob, extras=extras) am_msg = email.message.EmailMessage() hfrom = format_addrs([(i.get('Author', ''), i.get('Email'))]) am_msg.add_header('Subject', self.get_am_subject(indicate_reroll=False, use_subject=i.get('Subject'))) am_msg.add_header('From', hfrom) am_msg.add_header('Date', i.get('Date')) am_msg.add_header('Message-Id', f'<{self.msgid}>') am_msg.set_payload(self.body, charset='utf-8') return am_msg class LoreSubject: full_subject: str subject: str reply: bool resend: bool patch: bool rfc: bool revision: int counter: int expected: int revision_inferred: bool counters_inferred: bool prefixes: List[str] def __init__(self, subject): # Subject-based info self.reply = False self.resend = False self.patch = False self.rfc = False self.revision = 1 self.counter = 1 self.expected = 1 self.revision_inferred = True self.counters_inferred = True self.prefixes = list() subject = re.sub(r'\s+', ' ', LoreMessage.clean_header(subject)).strip() self.full_subject = subject # Is it a reply? if re.search(r'^(Re|Aw|Fwd):', subject, re.I) or re.search(r'^\w{2,3}:\s*\[', subject): self.reply = True self.subject = subject # We don't care to parse the rest return # Remove any brackets inside brackets while True: oldsubj = subject subject = re.sub(r'\[([^]]*)\[([^\[\]]*)]', r'[\1\2]', subject) subject = re.sub(r'\[([^]]*)]([^\[\]]*)]', r'[\1\2]', subject) if oldsubj == subject: break # Find all [foo] in the title while subject.find('[') == 0: matches = re.search(r'^\[([^]]*)]', subject) if not matches: break bracketed = matches.groups()[0].strip() # Fix [PATCHv3] to be properly [PATCH v3] bracketed = re.sub(r'(patch)(v\d+)', r'\1 \2', bracketed, flags=re.I) for chunk in bracketed.split(): # Remove any trailing commas or semicolons chunk = chunk.strip(',;') if re.search(r'^\d{1,4}/\d{1,4}$', chunk): counters = chunk.split('/') self.counter = int(counters[0]) self.expected = int(counters[1]) self.counters_inferred = False elif re.search(r'^v\d+$', chunk, re.IGNORECASE): self.revision = int(chunk[1:]) self.revision_inferred = False elif chunk.lower().find('rfc') == 0: self.rfc = True elif chunk.lower().find('resend') == 0: self.resend = True elif chunk.lower().find('patch') == 0: self.patch = True self.prefixes.append(chunk) subject = re.sub(r'^\s*\[[^]]*]\s*', '', subject) self.subject = subject def get_extra_prefixes(self, exclude: Optional[List[str]] = None) -> List[str]: ret = list() for _prf in self.prefixes: if exclude and _prf in exclude: continue if _prf.lower() == 'patch': continue elif re.search(r'v\d+', _prf, flags=re.I): continue elif re.search(r'\d+/\d+', _prf): continue ret.append(_prf) return ret def get_rebuilt_subject(self, eprefixes: Optional[List[str]] = None) -> str: _pfx = self.get_extra_prefixes() if eprefixes: for _epfx in eprefixes: if _epfx not in _pfx: _pfx.append(_epfx) if self.revision > 1: _pfx.append(f'v{self.revision}') if self.expected > 1: _pfx.append('%s/%s' % (str(self.counter).zfill(len(str(self.expected))), self.expected)) if len(_pfx): return '[PATCH ' + ' '.join(_pfx) + '] ' + self.subject else: return f'[PATCH] {self.subject}' def get_slug(self, sep='_', with_counter: bool = True) -> str: 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) -> str: out = list() out.append(' full_subject: %s' % self.full_subject) out.append(' subject: %s' % self.subject) out.append(' reply: %s' % self.reply) out.append(' resend: %s' % self.resend) out.append(' patch: %s' % self.patch) out.append(' rfc: %s' % self.rfc) out.append(' revision: %s' % self.revision) out.append(' revision_inferred: %s' % self.revision_inferred) out.append(' counter: %s' % self.counter) out.append(' expected: %s' % self.expected) out.append(' counters_inferred: %s' % self.counters_inferred) out.append(' prefixes: %s' % ', '.join(self.prefixes)) return '\n'.join(out) class LoreAttestor: mode: Optional[str] level: Optional[str] identity: Optional[str] signtime: Optional[any] keysrc: Optional[str] keyalgo: Optional[str] passing: bool have_key: bool errors: list def __init__(self) -> None: self.mode = None self.level = None self.identity = None self.signtime = None self.keysrc = None self.keyalgo = None self.passing = False self.have_key = False self.errors = list() @property def checkmark(self) -> str: config = get_main_config() if config['attestation-checkmarks'] == 'fancy': if self.passing: return ATT_PASS_FANCY return ATT_FAIL_FANCY if self.passing: return ATT_PASS_SIMPLE return ATT_FAIL_SIMPLE @property def trailer(self): if self.keyalgo: mode = self.keyalgo else: mode = self.mode 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: return False maxdrift = datetime.timedelta(days=maxdays) sdrift = self.signtime - emldate if sdrift > maxdrift: self.errors.append('Time drift between Date and t too great (%s)' % sdrift) return False logger.debug('PASS : time drift between Date and t (%s)', sdrift) return True def check_identity(self, emlfrom: str) -> bool: if not self.passing or not emlfrom: return False if self.level == 'domain': 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.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)) return False @staticmethod def parse_ts(ts: Optional[str]) -> Optional[datetime.datetime]: try: return datetime.datetime.fromtimestamp(int(ts), datetime.timezone.utc) except: # noqa logger.debug('Failed parsing t=%s', ts) return None def __repr__(self): out = list() out.append(' mode: %s' % self.mode) out.append(' level: %s' % self.level) out.append('identity: %s' % self.identity) out.append('signtime: %s' % self.signtime) out.append(' keysrc: %s' % self.keysrc) out.append(' keyalgo: %s' % self.keyalgo) out.append(' passing: %s' % self.passing) out.append('have_key: %s' % self.have_key) out.append(' errors: %s' % ','.join(self.errors)) return '\n'.join(out) class LoreAttestorDKIM(LoreAttestor): def __init__(self, passing: bool, identity: str, signtime: Optional[any], errors: list) -> None: super().__init__() self.mode = 'DKIM' self.level = 'domain' self.keysrc = 'DNS' self.signtime = signtime self.passing = passing self.errors = errors if identity.find('@') >= 0: self.identity = identity.split('@')[1] else: self.identity = identity class LoreAttestorPatatt(LoreAttestor): def __init__(self, result: bool, identity: str, signtime: Optional[any], keysrc: str, keyalgo: str, errors: list) -> None: super().__init__() self.mode = 'patatt' self.level = 'person' self.identity = identity self.signtime = signtime self.keysrc = keysrc self.keyalgo = keyalgo self.errors = errors if result == patatt.RES_VALID: self.passing = True self.have_key = True elif result >= patatt.RES_BADSIG: self.have_key = True 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 def gpg_run_command(args: List[str], stdin: Optional[bytes] = None) -> Tuple[int, bytes, bytes]: config = get_main_config() cmdargs = [config['gpgbin'], '--batch', '--no-auto-key-retrieve', '--no-auto-check-trustdb'] if config['attestation-gnupghome'] is not None: cmdargs += ['--homedir', config['attestation-gnupghome']] cmdargs += args return _run_command(cmdargs, stdin=stdin) def git_run_command(gitdir: Optional[Union[str, Path]], args: List[str], stdin: Optional[bytes] = None, logstderr: bool = False, decode: bool = True, rundir: Optional[str] = None) -> Tuple[int, Union[str, bytes]]: cmdargs = ['git', '--no-pager'] if gitdir: if os.path.exists(os.path.join(gitdir, '.git')): gitdir = os.path.join(gitdir, '.git') cmdargs += ['--git-dir', gitdir] # counteract some potential local settings if args[0] == 'log': args.insert(1, '--no-abbrev-commit') cmdargs += args ecode, out, err = _run_command(cmdargs, stdin=stdin, rundir=rundir) if decode: out = out.decode(errors='replace') if logstderr and len(err.strip()): if decode: err = err.decode(errors='replace') logger.debug('Stderr: %s', err) out += err return ecode, out def git_credential_fill(gitdir: Optional[str], protocol: str, host: str, username: str) -> Optional[str]: stdin = f'protocol={protocol}\nhost={host}\nusername={username}\n'.encode() ecode, out = git_run_command(gitdir, args=['credential', 'fill'], stdin=stdin) if ecode == 0: for line in out.splitlines(): if not line.startswith('password='): continue chunks = line.split('=', maxsplit=1) return chunks[1] return None def git_get_command_lines(gitdir: Optional[str], args: list) -> List[str]: ecode, out = git_run_command(gitdir, args) lines = list() if out: for line in out.split('\n'): if line == '': continue lines.append(line) 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: Optional[str] = None, commitish: Optional[str] = None) -> Optional[Iterator[Path]]: """Context manager that creates a temporary work tree and chdirs into it. The worktree is deleted when the contex manager is closed. Taken from gj_tools.""" dfn = None try: with tempfile.TemporaryDirectory() as dfn: gitargs = ['worktree', 'add', '--detach', '--no-checkout', dfn] if commitish: gitargs.append(commitish) git_run_command(gitdir, gitargs) with in_directory(dfn): yield dfn finally: if dfn is not None: git_run_command(gitdir, ['worktree', 'remove', '--force', dfn]) @contextmanager def git_temp_clone(gitdir: Optional[str] = None) -> Optional[Iterator[Path]]: """Context manager that creates a temporary shared clone.""" if gitdir is None: topdir = git_get_toplevel() if topdir and os.path.isdir(os.path.join(topdir, '.git')): gitdir = os.path.join(topdir, '.git') if not gitdir: logger.critical('Current directory is not a git checkout. Try using -g.') return None with tempfile.TemporaryDirectory() as dfn: gitargs = ['clone', '--mirror', '--shared', gitdir, dfn] git_run_command(None, gitargs) yield dfn @contextmanager def in_directory(dirname: str) -> Iterator[bool]: """Context manager that chdirs into a directory and restores the original directory when closed. Taken from gj_tools.""" cdir = os.getcwd() try: os.chdir(dirname) yield True finally: os.chdir(cdir) def setup_config(cmdargs: argparse.Namespace): """Setup configuration options. Needs to be called before accessing any of the config options.""" _setup_main_config(cmdargs) _setup_user_config(cmdargs) # Depends on main config! _setup_sendemail_config(cmdargs) def _cmdline_config_override(cmdargs: argparse.Namespace, config: dict, section: str): """Use cmdline.config to set and override config values for section.""" if not cmdargs.config: return section += '.' config_override = { key[len(section):]: val for key, val in cmdargs.config.items() if key.startswith(section) } config.update(config_override) 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, source: Optional[str] = None) -> dict: if multivals is None: multivals = list() args = ['config'] if source: args += ['--file', source] args += ['-z', '--get-regexp', regexp] ecode, out = git_run_command(None, args) gitconfig = defaults if not gitconfig: gitconfig = dict() if not out: return gitconfig for line in out.split('\x00'): if not line: continue key, value = line.split('\n', 1) try: chunks = key.split('.') cfgkey = chunks[-1].lower() if cfgkey in multivals: if cfgkey not in gitconfig: gitconfig[cfgkey] = list() gitconfig[cfgkey].append(value) else: gitconfig[cfgkey] = value except ValueError: logger.debug('Ignoring git config entry %s', line) return gitconfig def _setup_main_config(cmdargs: argparse.Namespace) -> None: global MAIN_CONFIG defcfg = copy.deepcopy(DEFAULT_CONFIG) # some options can be provided via the toplevel .b4-config file, # so load them up and use as defaults topdir = git_get_toplevel() wtglobs = ['send-*', '*mask', '*template*', 'trailer*', 'pw-*'] if topdir: wtcfg = os.path.join(topdir, '.b4-config') if os.access(wtcfg, os.R_OK): logger.debug('Loading worktree configs from %s', wtcfg) wtconfig = get_config_from_git(r'b4\..*', source=wtcfg) logger.debug('wtcfg=%s', wtconfig) for key, val in wtconfig.items(): if val.startswith('./'): # replace it with full topdir path val = os.path.abspath(os.path.join(topdir, val)) for wtglob in wtglobs: if fnmatch.fnmatch(key, wtglob): logger.debug('wtcfg: %s=%s', key, val) defcfg[key] = val break config = get_config_from_git(r'b4\..*', defaults=defcfg, multivals=['keyringsrc']) config['listid-preference'] = config['listid-preference'].split(',') config['listid-preference'].remove('*') config['listid-preference'].append('*') if config['gpgbin'] is None: gpgcfg = get_config_from_git(r'gpg\..*', {'program': 'gpg'}) config['gpgbin'] = gpgcfg['program'] _cmdline_config_override(cmdargs, config, 'b4') MAIN_CONFIG = config def get_main_config() -> Dict[str, Optional[Union[str, List[str]]]]: return MAIN_CONFIG def get_data_dir(appname: str = 'b4') -> str: if 'XDG_DATA_HOME' in os.environ: datahome = os.environ['XDG_DATA_HOME'] else: datahome = os.path.join(str(pathlib.Path.home()), '.local', 'share') datadir = os.path.join(datahome, appname) pathlib.Path(datadir).mkdir(parents=True, exist_ok=True) return datadir def get_cache_dir(appname: str = 'b4') -> str: global _CACHE_CLEANED if 'XDG_CACHE_HOME' in os.environ: cachehome = os.environ['XDG_CACHE_HOME'] else: cachehome = os.path.join(str(pathlib.Path.home()), '.cache') cachedir = os.path.join(cachehome, appname) pathlib.Path(cachedir).mkdir(parents=True, exist_ok=True) if _CACHE_CLEANED: return cachedir # Delete all .mbx and .lookup files older than cache-expire config = get_main_config() try: expmin = int(config['cache-expire']) * 60 except ValueError: logger.critical('ERROR: cache-expire must be an integer (minutes): %s', config['cache-expire']) expmin = 600 expage = time.time() - expmin for entry in os.listdir(cachedir): if entry.find('.mbx') <= 0 and entry.find('.lookup') <= 0 and entry.find('.msgs') <= 0: continue fullpath = os.path.join(cachedir, entry) st = os.stat(fullpath) if st.st_mtime < expage: logger.debug('Cleaning up cache: %s', entry) if os.path.isdir(fullpath): shutil.rmtree(fullpath) else: os.unlink(os.path.join(cachedir, entry)) _CACHE_CLEANED = True return cachedir def get_cache_file(identifier: str, suffix: Optional[str] = None) -> str: cachedir = get_cache_dir() cachefile = hashlib.sha1(identifier.encode()).hexdigest() if suffix: cachefile = f'{cachefile}.{suffix}' return os.path.join(cachedir, cachefile) def get_cache(identifier: str, suffix: Optional[str] = None) -> Optional[str]: fullpath = get_cache_file(identifier, suffix=suffix) try: with open(fullpath) as fh: logger.debug('Using cache %s for %s', fullpath, identifier) return fh.read() except FileNotFoundError: logger.debug('Cache miss for %s', identifier) return None def clear_cache(identifier: str, suffix: Optional[str] = None) -> None: fullpath = get_cache_file(identifier, suffix=suffix) if os.path.exists(fullpath): os.unlink(fullpath) logger.debug('Removed cache %s for %s', fullpath, identifier) def save_cache(contents: str, identifier: str, suffix: Optional[str] = None, mode: str = 'w') -> None: fullpath = get_cache_file(identifier, suffix=suffix) try: with open(fullpath, mode) as fh: fh.write(contents) logger.debug('Saved cache %s for %s', fullpath, identifier) except FileNotFoundError: logger.debug('Could not write cache %s for %s', fullpath, identifier) def _setup_user_config(cmdargs: argparse.Namespace): global USER_CONFIG USER_CONFIG = get_config_from_git(r'user\..*') if 'name' not in USER_CONFIG: if 'GIT_COMMITTER_NAME' in os.environ: USER_CONFIG['name'] = os.environ['GIT_COMMITTER_NAME'] elif 'GIT_AUTHOR_NAME' in os.environ: USER_CONFIG['name'] = os.environ['GIT_AUTHOR_NAME'] else: udata = pwd.getpwuid(os.getuid()) USER_CONFIG['name'] = udata.pw_gecos if 'email' not in USER_CONFIG: if 'GIT_COMMITTER_EMAIL' in os.environ: USER_CONFIG['email'] = os.environ['GIT_COMMITTER_EMAIL'] elif 'GIT_AUTHOR_EMAIL' in os.environ: USER_CONFIG['email'] = os.environ['GIT_AUTHOR_EMAIL'] elif 'EMAIL' in os.environ: USER_CONFIG['email'] = os.environ['EMAIL'] _cmdline_config_override(cmdargs, USER_CONFIG, 'user') def get_user_config() -> Dict[str, Optional[Union[str, List[str]]]]: return USER_CONFIG def get_requests_session() -> requests.Session: global REQSESSION if REQSESSION is None: REQSESSION = requests.session() REQSESSION.headers.update({'User-Agent': 'b4/%s' % __VERSION__}) return REQSESSION def get_msgid_from_stdin() -> Optional[str]: if not sys.stdin.isatty(): from email.parser import BytesParser message = BytesParser().parsebytes( sys.stdin.buffer.read(), headersonly=True) return message.get('Message-ID', None) return None def get_msgid(cmdargs: argparse.Namespace) -> Optional[str]: if not cmdargs.msgid and not cmdargs.no_stdin: logger.debug('Getting Message-ID from stdin') msgid = get_msgid_from_stdin() else: msgid = cmdargs.msgid if msgid is None: return None 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]) # Handle special case when msgid is prepended by id: or rfc822msgid: if msgid.find('id:') >= 0: msgid = re.sub(r'^\w*id:', '', msgid) return msgid def get_strict_thread(msgs: Union[List[email.message.Message], mailbox.Mailbox, mailbox.Maildir], msgid: str, noparent: bool = False) -> Optional[List[email.message.Message]]: want = {msgid} ignore = set() got = set() seen = set() maybe = dict() strict = list() 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 logger.debug('Looking at: %s', c_msgid) refs = set() msgrefs = list() if msg.get('In-Reply-To', None): 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): refs.add(ref) if c_msgid not in want: if ref not in maybe: maybe[ref] = set() logger.debug('Going into maybe: %s->%s', ref, c_msgid) maybe[ref].add(c_msgid) if c_msgid in want: strict.append(msg) got.add(c_msgid) want.update(refs) want.discard(c_msgid) logger.debug('Kept in thread: %s', c_msgid) if c_msgid in maybe: # Add all these to want want.update(maybe[c_msgid]) maybe.pop(c_msgid) # Add all maybes that have the same ref into want for ref in refs: if ref in maybe: want.update(maybe[ref]) maybe.pop(ref) # Remove any entries not in "seen" (missing messages) for c_msgid in set(want): if c_msgid not in seen or c_msgid in got: want.remove(c_msgid) if not len(want): break if not len(strict): return None if len(msgs) > len(strict): logger.debug('Reduced thread to requested matches only (%s->%s)', len(msgs), len(strict)) return strict def mailsplit_bytes(bmbox: bytes, outdir: str, pipesep: Optional[str] = None) -> List[email.message.Message]: msgs = list() if pipesep: logger.debug('Mailsplitting using pipesep=%s', pipesep) if '\\' in pipesep: import codecs pipesep = codecs.decode(pipesep.encode(), 'unicode_escape') for chunk in bmbox.split(pipesep.encode()): if chunk.strip(): msgs.append(email.message_from_bytes(chunk, policy=emlpolicy)) return msgs logger.debug('Mailsplitting the mbox into %s', outdir) args = ['mailsplit', '--mboxrd', '-o%s' % outdir] ecode, out = git_run_command(None, args, stdin=bmbox) if ecode > 0: logger.critical('Unable to parse mbox received from the server') return msgs # Read in the files for msg in os.listdir(outdir): with open(os.path.join(outdir, msg), 'rb') as fh: msgs.append(email.message_from_binary_file(fh, policy=emlpolicy)) return msgs def get_pi_search_results(query: str, nocache: bool = False, message: Optional[str] = None) -> Optional[List[email.message.Message]]: config = get_main_config() searchmask = config.get('searchmask') if not searchmask: logger.critical('b4.searchmask is not defined') return None msgs = list() 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, policy=emlpolicy)) return msgs loc = urllib.parse.urlparse(query_url) if message is not None and len(message): logger.info(message, loc.netloc) else: 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 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(policy=emlpolicy)) return msgs def get_pi_thread_by_url(t_mbx_url: str, nocache: bool = False) -> Optional[List[email.message.Message]]: 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, policy=emlpolicy)) 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, nocache: bool = False, onlymsgids: Optional[set] = None, with_thread: bool = True) -> Optional[List[email.message.Message]]: qmsgid = urllib.parse.quote_plus(msgid, safe='@') config = get_main_config() 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/'): projurl = '%s://%s/all' % (loc.scheme, loc.netloc) 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) if resp.status_code < 300 or resp.status_code > 400: logger.critical('That message-id is not known.') return None # Pop msgid from the end of the redirect chunks = resp.headers['Location'].rstrip('/').split('/') projurl = '/'.join(chunks[:-1]) resp.close() t_mbx_url = '%s/%s/t.mbox.gz' % (projurl, qmsgid) logger.debug('t_mbx_url=%s', t_mbx_url) msgs = get_pi_thread_by_url(t_mbx_url, nocache=nocache) if not msgs: return None if onlymsgids: strict = list() for msg in msgs: if LoreMessage.get_clean_msgid(msg) in onlymsgids: strict.append(msg) # also grab any messages where this msgid is in the references header if with_thread: for onlymsgid in onlymsgids: if msg.get('references', '').find(onlymsgid) >= 0: strict.append(msg) else: strict = get_strict_thread(msgs, msgid) return strict def git_range_to_patches(gitdir: Optional[str], start: str, end: str, prefixes: Optional[List[str]] = None, revision: Optional[int] = 1, 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) -> List[Tuple[str, email.message.Message]]: 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() # Go through them once to drop ignored commits and get bodies patches = list() 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', '--patch-with-stat', '--encoding=utf-8', commit], decode=False) if ecode > 0: raise RuntimeError(f'Could not get a patch out of {commit}') msg = email.message_from_bytes(out, policy=emlpolicy) patches.append((commit, msg)) fullcount = len(patches) if fullcount == 0: raise RuntimeError(f'Could not run rev-list {start}..{end}') vlines = git_get_command_lines(None, ['--version']) if len(vlines) == 1: gitver = vlines[0].split()[-1] else: gitver = None expected = len(patches) for counter, (commit, msg) in enumerate(patches): msg.set_charset('utf-8') # Clean From to remove any 7bit-safe encoding origfrom = LoreMessage.clean_header(msg.get('From')) lsubject = LoreSubject(msg.get('Subject')) lsubject.counter = counter + 1 lsubject.expected = expected lsubject.revision = revision subject = lsubject.get_rebuilt_subject(eprefixes=prefixes) logger.debug(' %s', subject) msg.replace_header('Subject', subject) inbodyhdrs = list() setfrom = origfrom if mailfrom: # Move the original From and Date into the body origpair = email.utils.parseaddr(origfrom) if origpair[1] != mailfrom[1]: setfrom = format_addrs([mailfrom]) inbodyhdrs.append(f'From: {origfrom}') msg.replace_header('From', setfrom) if seriests: patchts = seriests + counter + 1 origdate = msg.get('Date') if 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(decode=True) if isinstance(payload, bytes): payload = payload.decode() if inbodyhdrs: payload = '\n'.join(inbodyhdrs) + '\n\n' + payload if gitver and not payload.find('\n-- \n') > 0: payload += f'\n-- \n{gitver}\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(lsubject.counter)) return patches def git_commit_exists(gitdir: Optional[str], commit_id: str) -> bool: gitargs = ['cat-file', '-e', commit_id] ecode, out = git_run_command(gitdir, gitargs) return ecode == 0 def git_branch_exists(gitdir: Optional[str], branch_name: str) -> bool: gitargs = ['rev-parse', branch_name] ecode, out = git_run_command(gitdir, gitargs) return ecode == 0 def git_revparse_tag(gitdir: Optional[str], tagname: str) -> Optional[str]: if not tagname.startswith('refs/tags/'): fulltag = f'refs/tags/{tagname}' else: fulltag = tagname gitargs = ['rev-parse', fulltag] ecode, out = git_run_command(gitdir, gitargs) if ecode > 0: return None return out.strip() def git_branch_contains(gitdir: Optional[str], commit_id: str, checkall: bool = False) -> List[str]: gitargs = ['branch', '--format=%(refname:short)', '--contains', commit_id] if checkall: gitargs.append('--all') lines = git_get_command_lines(gitdir, gitargs) return lines def git_get_toplevel(path: Optional[str] = None) -> Optional[str]: topdir = None # Are we in a git tree and if so, what is our toplevel? gitargs = ['rev-parse', '--show-toplevel'] lines = git_get_command_lines(path, gitargs) if len(lines) == 1: topdir = lines[0] return topdir def format_addrs(pairs: List[Tuple[str, str]], clean: bool = True) -> str: addrs = list() for pair in pairs: if pair[0] == pair[1]: addrs.append(pair[1]) continue if clean: # Remove any quoted-printable header junk from the name pair = (LoreMessage.clean_header(pair[0]), pair[1]) # Work around https://github.com/python/cpython/issues/100900 if not pair[0].startswith('=?') and not pair[0].startswith('"') and qspecials.search(pair[0]): quoted = email.utils.quote(pair[0]) addrs.append(f'"{quoted}" <{pair[1]}>') continue addrs.append(email.utils.formataddr(pair)) return ', '.join(addrs) def make_quote(body: str, maxlines: int = 5) -> str: headers, message, trailers, basement, signature = LoreMessage.get_body_parts(body) if not len(message): # Sometimes there is no message, just trailers return '> \n' # Remove common greetings message = re.sub(r'^(hi|hello|greetings|dear)\W.*\n+', '', message, flags=re.I) quotelines = list() qcount = 0 for line in message.split('\n'): # Quote the first paragraph only and then [snip] if we quoted more than maxlines if qcount > maxlines and not len(line.strip()): quotelines.append('> ') quotelines.append('> [...]') break quotelines.append('> %s' % line.rstrip()) qcount += 1 return '\n'.join(quotelines) def parse_int_range(intrange: str, upper: Optional[int] = None) -> Iterator[int]: # Remove all whitespace intrange = re.sub(r'\s', '', intrange) for n in intrange.split(','): if n.isdigit(): yield int(n) elif n.find('<') == 0 and len(n) > 1 and n[1:].isdigit(): yield from range(1, int(n[1:])) elif n.find('-') > 0: nr = n.split('-') if nr[0].isdigit() and nr[1].isdigit(): yield from range(int(nr[0]), int(nr[1]) + 1) elif not len(nr[1]) and nr[0].isdigit() and upper: yield from range(int(nr[0]), upper + 1) else: logger.critical('Unknown range value specified: %s', n) def check_gpg_status(status: str) -> Tuple[bool, bool, bool, Optional[str], Optional[str]]: good = False valid = False trusted = False keyid = None signtime = None # Do we have a BADSIG? 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 ([\dA-F]+)\s+(.*)$', status, flags=re.M) if gs_matches: good = True keyid = gs_matches.groups()[0] 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] ts_matches = re.search(r'^\[GNUPG:] TRUST_(FULLY|ULTIMATE)', status, flags=re.M) if ts_matches: trusted = True return good, valid, trusted, keyid, signtime def get_gpg_uids(keyid: str) -> List[str]: gpgargs = ['--with-colons', '--list-keys', keyid] ecode, out, err = gpg_run_command(gpgargs) if ecode > 0: raise KeyError('Unable to get UIDs list matching key %s' % keyid) keyinfo = out.decode() uids = list() for line in keyinfo.split('\n'): if line[:4] != 'uid:': continue chunks = line.split(':') if chunks[1] in ('r',): # Revoked UID, ignore continue uids.append(chunks[9]) return uids def save_git_am_mbox(msgs: List[email.message.Message], dest: BinaryIO) -> None: # Git-am has its own understanding of what "mbox" format is that differs from Python's # mboxo implementation. Specifically, it never escapes the ">From " lines found in bodies # unless invoked with --patch-format=mboxrd (this is wrong, because ">From " escapes are also # required in the original mbox "mboxo" format). # So, save in the format that git-am expects for msg in msgs: dest.write(b'From git@z Thu Jan 1 00:00:00 1970\n') dest.write(LoreMessage.get_msg_as_bytes(msg, headers='decode')) def save_mboxrd_mbox(msgs: List[email.message.Message], dest: BinaryIO, mangle_from: bool = False) -> None: gen = email.generator.BytesGenerator(dest, mangle_from_=mangle_from, policy=emlpolicy) for msg in msgs: dest.write(b'From mboxrd@z Thu Jan 1 00:00:00 1970\n') gen.flatten(msg) def save_maildir(msgs: list, dest) -> None: d_new = os.path.join(dest, 'new') pathlib.Path(d_new).mkdir(parents=True) d_cur = os.path.join(dest, 'cur') pathlib.Path(d_cur).mkdir(parents=True) d_tmp = os.path.join(dest, 'tmp') pathlib.Path(d_tmp).mkdir(parents=True) for msg in msgs: # make a slug out of it lsubj = LoreSubject(msg.get('subject', '')) slug = '%04d_%s' % (lsubj.counter, re.sub(r'\W+', '_', lsubj.subject).strip('_').lower()) with open(os.path.join(d_tmp, f'{slug}.eml'), 'wb') as mfh: mfh.write(LoreMessage.get_msg_as_bytes(msg, headers='decode')) os.rename(os.path.join(d_tmp, f'{slug}.eml'), os.path.join(d_new, f'{slug}.eml')) def get_mailinfo(bmsg: bytes, scissors: bool = False) -> Tuple[dict, bytes, bytes]: with tempfile.TemporaryDirectory() as tfd: m_out = os.path.join(tfd, 'm') p_out = os.path.join(tfd, 'p') if scissors: cmdargs = ['mailinfo', '--encoding=UTF-8', '--scissors', m_out, p_out] else: cmdargs = ['mailinfo', '--encoding=UTF-8', '--no-scissors', m_out, p_out] ecode, info = git_run_command(None, cmdargs, bmsg) if not len(info.strip()): raise ValueError('Could not get mailinfo') i = dict() m = b'' p = b'' for line in info.split('\n'): line = line.strip() if not line: continue chunks = line.split(':', 1) i[chunks[0]] = chunks[1].strip() with open(m_out, 'rb') as mfh: m = mfh.read() with open(p_out, 'rb') as pfh: p = pfh.read() return i, m, p def read_template(tptfile: str) -> str: # 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 _setup_sendemail_config(cmdargs: argparse.Namespace) -> None: global SENDEMAIL_CONFIG # Get the default settings first config = get_main_config() identity = config.get('sendemail-identity') _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}' if not len(sconfig): raise smtplib.SMTPException('Unable to find %s settings in any applicable git config' % sectname) else: sconfig = _basecfg sectname = 'sendemail' logger.debug('Using values from %s', sectname) # Note: This can't handle identity, need to use sendemail.key directly _cmdline_config_override(cmdargs, sconfig, 'sendemail') SENDEMAIL_CONFIG = sconfig def get_sendemail_config() -> Dict[str, Optional[Union[str, List[str]]]]: return SENDEMAIL_CONFIG def get_smtp(dryrun: bool = False) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, list, None], str]: sconfig = get_sendemail_config() # Limited support for smtp settings to begin with, but should cover the vast majority of cases fromaddr = sconfig.get('from') if not fromaddr: # We fall back to user.email usercfg = get_user_config() fromaddr = usercfg['email'] server = sconfig.get('smtpserver', 'localhost') port = sconfig.get('smtpserverport', 0) try: port = int(port) except ValueError: raise smtplib.SMTPException('Invalid smtpport entry in config') # 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) if '-i' not in smtp: smtp.append('-i') # Do we have the envelopesender defined? env_sender = sconfig.get('envelopesender', '') if env_sender: if env_sender == 'auto': envpair = email.utils.parseaddr(fromaddr) else: envpair = email.utils.parseaddr(env_sender) if envpair[1]: smtp += ['-f', envpair[1]] logger.debug('sendmail command: %s', ' '.join(smtp)) 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 not apass: # Try with git-credential-helper if port: gchost = f'{server}:{port}' else: gchost = server apass = git_credential_fill(None, protocol='smtp', host=gchost, username=auser) if not apass: raise smtplib.SMTPException('No password specified for connecting to %s', server) 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 requires pw-key, pw-url and pw-project settings') 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 try: pwdata = LoreMessage.get_patchwork_data_by_msgid(msgid) patch_id = pwdata.get('id') if patch_id: title = pwdata.get('name') if pwdata.get('state') != state: seen.add(msgid) tochange.append((patch_id, title)) # invalidate the cache clear_cache(pwurl + pwproj + msgid, suffix='lookup') except LookupError as ex: logger.debug('Error retrieving patchwork data: %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, output_dir: Optional[str] = None, web_endpoint: Optional[str] = None, reflect: 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') if dryrun or web_endpoint: nl = '\n' else: nl = '\r\n' bdata = LoreMessage.get_msg_as_bytes(msg, nl=nl, headers='encode') subject = msg.get('Subject', '') ls = LoreSubject(subject) if patatt_sign: import patatt # patatt.logger = logger try: bdata = patatt.rfc2822_sign(bdata) except patatt.NoKeyError as ex: logger.critical('CRITICAL: Error signing: no key configured') logger.critical(' Run "patatt genkey" or configure "user.signingKey" to use PGP') logger.critical(' As a last resort, rerun with --no-sign') raise RuntimeError(str(ex)) except patatt.SigningError as ex: raise RuntimeError('Failure trying to patatt-sign: %s' % str(ex)) 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', [])]) myaddrs = {x[1] for x in alldests} else: myaddrs = destaddrs tosend.append((myaddrs, bdata, ls)) if not len(tosend): return 0 logger.info('---') if web_endpoint: if reflect: logger.info('Reflecting via web endpoint %s', web_endpoint) wpaction = 'reflect' else: logger.info('Sending via web endpoint %s', web_endpoint) wpaction = 'receive' req = { 'action': wpaction, 'messages': [x[1].decode() for x in tosend], } ses = get_requests_session() res = ses.post(web_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 envpair = email.utils.parseaddr(fromaddr) if isinstance(smtp, list): # This is a local command if reflect: logger.info('Reflecting via "%s"', ' '.join(smtp)) else: logger.info('Sending via "%s"', ' '.join(smtp)) for destaddrs, bdata, lsubject in tosend: logger.info(' %s', lsubject.full_subject) if reflect: cmdargs = list(smtp) + [envpair[1]] else: cmdargs = list(smtp) + list(destaddrs) ecode, out, err = _run_command(cmdargs, 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) if reflect: smtp.sendmail(fromaddr, [envpair[1]], bdata) else: 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.debug('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 def retrieve_messages(cmdargs: argparse.Namespace) -> Tuple[Optional[str], Optional[list]]: msgid = None with_thread = True pickings = set() if 'singlemsg' in cmdargs and cmdargs.singlemsg: logger.info('Single-message mode, ignoring any follow-ups') with_thread = False if not cmdargs.localmbox: if not can_network: raise LookupError('Cannot retrieve threads from remote in offline mode') msgid = get_msgid(cmdargs) if not msgid: raise LookupError('Pipe a message or pass msgid as parameter') if ('cherrypick' in cmdargs and cmdargs.cherrypick == '_') or not with_thread: # Just that msgid, please pickings.add(msgid) msgs = get_pi_thread_by_msgid(msgid, nocache=cmdargs.nocache, onlymsgids=pickings, with_thread=with_thread) if not msgs: logger.debug('No messages from the query') return None, msgs else: if cmdargs.localmbox == '-': # The entire mbox is passed via stdin, so mailsplit it and use the first message for our msgid with tempfile.TemporaryDirectory() as tfd: msgs = mailsplit_bytes(sys.stdin.buffer.read(), tfd, pipesep=cmdargs.stdin_pipe_sep) if not len(msgs): raise LookupError('Stdin did not contain any messages') elif os.path.exists(cmdargs.localmbox): msgid = get_msgid(cmdargs) if os.path.isdir(cmdargs.localmbox): in_mbx = mailbox.Maildir(cmdargs.localmbox) else: in_mbx = mailbox.mbox(cmdargs.localmbox) if msgid: if with_thread: msgs = get_strict_thread(in_mbx, msgid) if not msgs: raise LookupError('Could not find %s in %s' % (msgid, cmdargs.localmbox)) else: msgs = list() for msg in in_mbx: if LoreMessage.get_clean_msgid(msg) == msgid: msgs = [msg] break else: msgs = in_mbx else: raise LookupError('Mailbox %s does not exist' % cmdargs.localmbox) if msgid and 'noparent' in cmdargs and cmdargs.noparent: msgs = get_strict_thread(msgs, msgid, noparent=True) if not msgid and msgs: for msg in msgs: msgid = msg.get('Message-ID', None) if msgid: msgid = msgid.strip('<>') break return msgid, msgs def git_revparse_obj(gitobj: str, gitdir: Optional[str] = None) -> str: ecode, out = git_run_command(gitdir, ['rev-parse', gitobj]) if ecode > 0: raise RuntimeError('No such object: %s' % gitobj) return out.strip() def git_fetch_am_into_repo(gitdir: Optional[str], am_msgs: List[email.message.Message], at_base: str = 'HEAD', origin: str = None): ifh = io.BytesIO() save_git_am_mbox(am_msgs, ifh) ambytes = ifh.getvalue() if gitdir is None: gitdir = os.getcwd() topdir = git_get_toplevel(gitdir) with git_temp_worktree(topdir, at_base) as gwt: logger.info('Magic: Preparing a sparse worktree') ecode, out = git_run_command(gwt, ['sparse-checkout', 'init'], logstderr=True) if ecode > 0: logger.critical('Error running sparse-checkout init') logger.critical(out) raise RuntimeError ecode, out = git_run_command(gwt, ['checkout'], logstderr=True) if ecode > 0: logger.critical('Error running checkout into sparse workdir') logger.critical(out) raise RuntimeError ecode, out = 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') raise RuntimeError logger.info('---') logger.info(out.strip()) logger.info('---') logger.info('Fetching into FETCH_HEAD') gitargs = ['fetch', gwt] ecode, out = git_run_command(topdir, gitargs, logstderr=True) if ecode > 0: logger.critical('Unable to fetch from the worktree') logger.critical(out.strip()) raise RuntimeError if not origin: return # Update the FETCH_HEAD to point where we actually fetched from gitargs = ['rev-parse', '--git-path', 'FETCH_HEAD'] ecode, fhf = git_run_command(topdir, gitargs, logstderr=True) if ecode > 0: logger.critical('Unable to find FETCH_HEAD') logger.critical(out.strip()) raise RuntimeError with open(fhf.rstrip(), 'r') as fhh: contents = fhh.read() if len(am_msgs) > 1: mmsg = 'patches from %s' % origin else: mmsg = 'patch from %s' % origin new_contents = contents.replace(str(gwt), mmsg) if new_contents != contents: with open(fhf.rstrip(), 'w') as fhh: fhh.write(new_contents) b4-0.13.0/b4/command.py000066400000000000000000000541211456345453500144160ustar00rootroot00000000000000#!/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 argparse import logging import b4 import sys logger = b4.logger def cmd_retrieval_common_opts(sp): sp.add_argument('msgid', nargs='?', help='Message ID to process, or pipe a raw message') 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('--stdin-pipe-sep', help='When accepting messages on stdin, split using this pipe separator string') sp.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False, help='Do not use local cache') sp.add_argument('--single-message', dest='singlemsg', action='store_true', default=False, help='Only retrieve the message matching the msgid and ignore the rest of the thread') def cmd_mbox_common_opts(sp): cmd_retrieval_common_opts(sp) sp.add_argument('-o', '--outdir', default='.', help='Output into this directory (or use - to output mailbox contents to stdout)') sp.add_argument('-c', '--check-newer-revisions', dest='checknewer', action='store_true', default=False, help='Check if newer patch revisions exist') sp.add_argument('-n', '--mbox-name', dest='wantname', default=None, help='Filename to name the mbox destination') sp.add_argument('-M', '--save-as-maildir', dest='maildir', action='store_true', default=False, 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='(This is now the default behavior; this option will be removed in the future.)') 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) def cmd_kr(cmdargs): import b4.kr 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_shazam(cmdargs): import b4.mbox b4.mbox.main(cmdargs) def cmd_pr(cmdargs): import b4.pr b4.pr.main(cmdargs) def cmd_ty(cmdargs): import b4.ty b4.ty.main(cmdargs) def cmd_diff(cmdargs): import b4.diff b4.diff.main(cmdargs) class ConfigOption(argparse.Action): """Action class for storing key=value arguments in a dict.""" def __call__(self, parser, namespace, keyval, option_string=None): config = getattr(namespace, self.dest, None) if config is None: config = dict() setattr(namespace, self.dest, config) if '=' in keyval: key, value = keyval.split('=', maxsplit=1) else: # mimic git -c option key, value = keyval, 'true' config[key] = value def setup_parser() -> argparse.ArgumentParser: # noinspection PyTypeChecker parser = argparse.ArgumentParser( prog='b4', description='A tool to work with patches in public-inbox archives', epilog='Online docs available at https://b4.docs.kernel.org', formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument('--version', action='version', version=b4.__VERSION__) parser.add_argument('-d', '--debug', action='store_true', default=False, 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') parser.add_argument('--offline-mode', action='store_true', default=False, help='Do not perform any network queries') parser.add_argument('--no-stdin', action='store_true', default=False, help='Disable TTY detection for stdin') parser.add_argument('--use-web-endpoint', dest='send_web', action='store_true', default=False, help="Force going through the web endpoint") parser.add_argument('-c', '--config', metavar='NAME=VALUE', action=ConfigOption, help='''Set config option NAME to VALUE. Override value from config files. NAME is in dotted section.key format. Using NAME= and omitting VALUE will set the value to the empty string. Using NAME and omitting =VALUE will set the value to "true".''') subparsers = parser.add_subparsers(help='sub-command help', dest='subcmd') # b4 mbox sp_mbox = subparsers.add_parser('mbox', help='Download a thread as an mbox file') cmd_mbox_common_opts(sp_mbox) sp_mbox.add_argument('-f', '--filter-dupes', dest='filterdupes', action='store_true', default=False, help='When adding messages to existing maildir, filter out duplicates') sp_mbox.add_argument('-r', '--refetch', dest='refetch', metavar='MBOX', default=False, help='Refetch all messages in specified mbox with their original headers') sp_mbox.set_defaults(func=cmd_mbox) # 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) 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('-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', 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=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('--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 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.add_argument('--merge-base', dest='mergebase', type=str, default=None, help='(use with -H or -M) Force this base when merging') 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') sp_pr.add_argument('-g', '--gitdir', default=None, help='Operate on this git tree instead of current dir') sp_pr.add_argument('-b', '--branch', default=None, help='Check out FETCH_HEAD into this branch after fetching') sp_pr.add_argument('-c', '--check', action='store_true', default=False, help='Check if pull request has already been applied') sp_pr.add_argument('-e', '--explode', action='store_true', default=False, help='Convert a pull request into an mbox full of patches') sp_pr.add_argument('-o', '--output-mbox', dest='outmbox', default=None, help='Save exploded messages into this mailbox (default: msgid.mbx)') sp_pr.add_argument('-f', '--from-addr', dest='mailfrom', default=None, help='Use this From: in exploded messages (use with -e)') sp_pr.add_argument('-s', '--send-as-identity', dest='sendidentity', default=None, help=('Use git-send-email to send exploded series (use with -e);' 'the identity must match a [sendemail "identity"] config section')) sp_pr.add_argument('--dry-run', dest='dryrun', action='store_true', default=False, help='Force a --dry-run on git-send-email invocation (use with -s)') sp_pr.add_argument('msgid', nargs='?', help='Message ID to process, or pipe a raw message') sp_pr.set_defaults(func=cmd_pr) # b4 ty sp_ty = subparsers.add_parser('ty', help='Generate thanks email when something gets merged/applied') sp_ty.add_argument('-g', '--gitdir', default=None, help='Operate on this git tree instead of current dir') sp_ty.add_argument('-o', '--outdir', default='.', 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('-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")') sp_ty.add_argument('-a', '--auto', action='store_true', default=False, help='Use the Auto-Thankanator to figure out what got applied/merged') sp_ty.add_argument('-b', '--branch', default=None, 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 sp_diff = subparsers.add_parser('diff', help='Show a range-diff to previous series revision') sp_diff.add_argument('msgid', nargs='?', help='Message ID to process, or pipe a raw message') sp_diff.add_argument('-g', '--gitdir', default=None, help='Operate on this git tree instead of current dir') 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='+', help='Compare specific versions instead of latest and one before that, e.g. -v 3 5') sp_diff.add_argument('-n', '--no-diff', dest='nodiff', action='store_true', default=False, help='Do not generate a diff, just show the command to do it') sp_diff.add_argument('-o', '--output-diff', dest='outdiff', default=None, help='Save diff into this file instead of outputting to stdout') sp_diff.add_argument('-c', '--color', dest='color', action='store_true', default=False, help='Force color output even when writing to file') sp_diff.add_argument('-m', '--compare-am-mboxes', dest='ambox', nargs=2, default=None, help='Compare two mbx files prepared with "b4 am"') sp_diff.set_defaults(func=cmd_diff) # b4 kr sp_kr = subparsers.add_parser('kr', help='Keyring operations') cmd_retrieval_common_opts(sp_kr) sp_kr.add_argument('--show-keys', dest='showkeys', action='store_true', default=False, 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('-c', '--auto-to-cc', action='store_true', default=False, help='Automatically populate cover letter trailers with To and Cc addresses') sp_prep.add_argument('--force-revision', metavar='N', type=int, help='Force revision to be this number instead') sp_prep.add_argument('--set-prefixes', metavar='PREFIX', nargs='+', help='Extra prefixes to add to [PATCH] (e.g.: RFC mydrv)') spp_g = sp_prep.add_mutually_exclusive_group() spp_g.add_argument('-p', '--format-patch', metavar='OUTPUT_DIR', help='Output prep-tracked commits as patches') spp_g.add_argument('--edit-cover', action='store_true', default=False, help='Edit the cover letter in your defined $EDITOR (or core.editor)') spp_g.add_argument('--show-revision', action='store_true', default=False, help='Show current series revision number') spp_g.add_argument('--compare-to', metavar='vN', help='Display a range-diff to previously sent revision N') spp_g.add_argument('--manual-reroll', dest='reroll', default=None, metavar='COVER_MSGID', help='Mark current revision as sent and reroll (requires cover letter msgid)') spp_g.add_argument('--show-info', metavar='PARAM', nargs='?', const=':_all', help='Show series info in a format that can be passed to other commands.') spp_g.add_argument('--cleanup', metavar='BRANCHNAME', nargs='?', const='_show', help='Archive and remove a prep-tracked branch and all its sent/ tags') 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', nargs='?', const='@{upstream}', help='Enroll current branch, using its configured upstream branch as fork base, ' 'or the passed tag, branch, or commit') 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='trailers_from', 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)') cmd_retrieval_common_opts(sp_trl) 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_g = sp_send.add_mutually_exclusive_group() sp_send_g.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_g.add_argument('-o', '--output-dir', help='Do not send, write raw messages to this directory (forces --dry-run)') sp_send_g.add_argument('--preview-to', nargs='+', metavar='ADDR', help='Send everything for a pre-review to specified addresses instead of actual recipients') sp_send_g.add_argument('--reflect', action='store_true', default=False, help='Send everything to yourself instead of the actual recipients') sp_send.add_argument('--no-trailer-to-cc', action='store_true', default=False, help='Do not add any addresses found in the cover or patch trailers to To: or Cc:') sp_send.add_argument('--to', nargs='+', metavar='ADDR', help='Addresses to add to the To: list') sp_send.add_argument('--cc', nargs='+', metavar='ADDR', 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', metavar='vN', nargs='?', const='latest', help='Resend a previously sent version of the series') sp_send.add_argument('--no-sign', action='store_true', default=False, help='Do not add the cryptographic attestation signature header') 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) return parser def cmd(): parser = setup_parser() cmdargs = parser.parse_args() logger.setLevel(logging.DEBUG) ch = logging.StreamHandler() formatter = logging.Formatter('%(message)s') ch.setFormatter(formatter) if cmdargs.quiet: ch.setLevel(logging.CRITICAL) elif cmdargs.debug: ch.setLevel(logging.DEBUG) else: ch.setLevel(logging.INFO) logger.addHandler(ch) if 'func' not in cmdargs: parser.print_help() sys.exit(1) b4.setup_config(cmdargs) if cmdargs.offline_mode: logger.info('Running in OFFLINE mode') b4.can_network = False cmdargs.func(cmdargs) if __name__ == '__main__': # We're running from a checkout, so reflect git commit in the version import os # noinspection PyBroadException try: if b4.__VERSION__.find('-dev') > 0: base = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) dotgit = os.path.join(base, '.git') ecode, short = b4.git_run_command(dotgit, ['rev-parse', '--short', 'HEAD']) if ecode == 0: b4.__VERSION__ = '%s-%.5s' % (b4.__VERSION__, short.strip()) except Exception as ex: # Any failures above are non-fatal pass cmd() b4-0.13.0/b4/diff.py000066400000000000000000000135001456345453500137040ustar00rootroot00000000000000#!/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 os import sys import b4 import b4.mbox import mailbox import email import shutil import pathlib import argparse from typing import Tuple, Optional, List logger = b4.logger def diff_same_thread_series(cmdargs: argparse.Namespace) -> Tuple[Optional[b4.LoreSeries], Optional[b4.LoreSeries]]: msgid = b4.get_msgid(cmdargs) if not msgid: logger.critical('Please pass msgid on the command-line') sys.exit(1) wantvers = cmdargs.wantvers if wantvers and len(wantvers) > 2: logger.critical('Can only compare two versions at a time') sys.exit(1) # start by grabbing the mbox provided # Do we have a cache of this lookup? identifier = msgid if wantvers: identifier += '-' + '-'.join([str(x) for x in wantvers]) cachedir = b4.get_cache_file(identifier, suffix='diff.msgs') if os.path.exists(cachedir) and not cmdargs.nocache: logger.info('Using cached copy of the lookup') msgs = list() 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: msgs = b4.get_pi_thread_by_msgid(msgid, nocache=cmdargs.nocache) if not msgs: logger.critical('Unable to retrieve thread: %s', msgid) return None, None msgs = b4.mbox.get_extra_series(msgs, direction=-1, wantvers=wantvers) if os.path.exists(cachedir): shutil.rmtree(cachedir) pathlib.Path(cachedir).mkdir(parents=True) at = 0 for msg in msgs: with open(os.path.join(cachedir, '%04d' % at), 'wb') as fh: fh.write(msg.as_bytes(policy=b4.emlpolicy)) at += 1 count = len(msgs) logger.info('---') logger.info('Analyzing %s messages in the thread', count) lmbx = b4.LoreMailbox() for msg in msgs: lmbx.add_message(msg) if wantvers and len(wantvers) == 1: upper = max(lmbx.series.keys()) lower = wantvers[0] elif wantvers and len(wantvers) == 2: upper = max(wantvers) lower = min(wantvers) else: upper = max(lmbx.series.keys()) lower = upper while True: lower -= 1 if lower in lmbx.series: break if lower < 1: logger.critical('Could not find lower series to compare against.') sys.exit(1) if upper == lower: logger.critical('ERROR: Could not auto-find previous revision') logger.critical(' Run "b4 am -T" manually, then "b4 diff -m mbx1 mbx2"') return None, None if upper not in lmbx.series: return None, None if lower not in lmbx.series: return None, None if not lmbx.series[lower].complete: lmbx.partial_reroll(lower, sloppytrailers=False) if not lmbx.series[upper].complete: lmbx.partial_reroll(upper, sloppytrailers=False) return lmbx.series[lower], lmbx.series[upper] def diff_mboxes(cmdargs: argparse.Namespace) -> Optional[List[b4.LoreSeries]]: chunks = list() for mboxfile in cmdargs.ambox: if not os.path.exists(mboxfile): logger.critical('Cannot open %s', mboxfile) return None if os.path.isdir(mboxfile): mbx = mailbox.Maildir(mboxfile) else: mbx = mailbox.mbox(mboxfile) count = len(mbx) logger.info('Loading %s messages from %s', count, mboxfile) lmbx = b4.LoreMailbox() for key, msg in mbx.items(): lmbx.add_message(msg) if len(lmbx.series) < 1: logger.critical('No valid patches found in %s', mboxfile) sys.exit(1) if len(lmbx.series) > 1: logger.critical('More than one series version in %s, will use latest', mboxfile) chunks.append(lmbx.series[max(lmbx.series.keys())]) return chunks def main(cmdargs: argparse.Namespace) -> None: if cmdargs.ambox is not None: lser, user = diff_mboxes(cmdargs) else: lser, user = diff_same_thread_series(cmdargs) if lser is None or user is None: sys.exit(1) # Prepare the lower fake-am range lsc, lec = lser.make_fake_am_range(gitdir=cmdargs.gitdir) if lsc is None or lec is None: logger.critical('---') logger.critical('Could not create fake-am range for lower series v%s', lser.revision) sys.exit(1) # Prepare the upper fake-am range usc, uec = user.make_fake_am_range(gitdir=cmdargs.gitdir) if usc is None or uec is None: logger.critical('---') logger.critical('Could not create fake-am range for upper series v%s', user.revision) sys.exit(1) grdcmd = 'git range-diff %.12s..%.12s %.12s..%.12s' % (lsc, lec, usc, uec) if cmdargs.nodiff: logger.info('Success, to compare v%s and v%s:', lser.revision, user.revision) logger.info(f' {grdcmd}') sys.exit(0) logger.info('---') logger.info('Diffing v%s and v%s', lser.revision, user.revision) logger.info(' Running: %s', grdcmd) gitargs = ['range-diff', f'{lsc}..{lec}', f'{usc}..{uec}'] if cmdargs.outdiff is None or cmdargs.color: gitargs.append('--color') ecode, rdiff = b4.git_run_command(cmdargs.gitdir, gitargs) if ecode > 0: logger.critical('Unable to generate diff') logger.critical('Try running it yourself:') logger.critical(f' {grdcmd}') sys.exit(1) if cmdargs.outdiff is not None: logger.info('Writing %s', cmdargs.outdiff) fh = open(cmdargs.outdiff, 'w') else: logger.info('---') fh = sys.stdout fh.write(rdiff) b4-0.13.0/b4/ez.py000066400000000000000000002554561456345453500134340ustar00rootroot00000000000000#!/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 import io import tarfile from typing import Optional, Tuple, List, Union 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 ---' # Make this configurable? SENT_TAG_PREFIX = 'sent/' DEFAULT_ENDPOINT = 'https://lkml.kernel.org/_b4_submit' 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 re.search(r'^https?://', endpoint): endpoint = None if not endpoint: # Use the default endpoint if we are in the kernel repo topdir = b4.git_get_toplevel() if os.path.exists(os.path.join(topdir, 'Kconfig')): logger.debug('No sendemail configs found, will use the default web endpoint') endpoint = DEFAULT_ENDPOINT else: raise RuntimeError('Web submission endpoint (b4.send-endpoint-web) is not defined, or is not a web URL.') 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.encode(), 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_charset('utf-8') cmsg.set_payload(f'verify:{vstr}\n', charset='utf-8') 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, } 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: gitargs = ['merge-base', mybranch, 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: # If we have b=, strip that out (we only support a single format, # so there is currently no need to check what it's set to) if b64tracking.find('v=1; b=') >= 0: chunks = b64tracking.split('b=', maxsplit=1) b64tracking = chunks[1].strip() ztracking = base64.b64decode(b64tracking) btracking = gzip.decompress(ztracking) tracking = json.loads(btracking.decode()) logger.debug('tracking: %s', tracking) cover_sections = list() for section in re.split(r'^---\n', cmsg.body, flags=re.M): # we stop caring once we see a diffstat if b4.DIFFSTAT_RE.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 # Escape lines starting with "#" so they don't get lost cover = re.sub(r'^(#.*)$', r'>\1', cover, flags=re.M) 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 else: # Use the first patch as our thread_msgid thread_msgid = lser.patches[1].msgid # 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' if mybranch: basebranch = mybranch else: basebranch = '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 else: basebranch = mybranch if basebranch is None: logger.critical('CRITICAL: fork-point %s is not on the current branch.', cmdargs.fork_point) 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 # Convert @{upstream}, @{push} to an abbreviated ref gitargs = ['rev-parse', '--abbrev-ref', '--verify', enroll_base] ecode, out = b4.git_run_command(None, gitargs) if ecode > 0: if enroll_base == '@{upstream}' or enroll_base == '@{u}': logger.critical('CRITICAL: current branch has no configured upstream') sys.exit(1) elif out: enroll_base = out.strip() # Is it a branch? gitargs = ['show-ref', f'refs/heads/{enroll_base}', f'refs/remotes/{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:', enroll_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, checkall=True) 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, '', '# 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. If you', '# only have a single patch in your series, the contents of the cover', '# letter will be appended to the "under-the-cut" portion of the patch.', '', '# Lines starting with # will be removed from the cover letter. You can', '# use them to add notes or reminders to yourself. If you want to use', '# markdown headers in your cover letter, start the line with ">#".', '', '# You can add 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. You can also run "b4 prep --auto-to-cc" to', '# auto-populate the To: and Cc: trailers based on the code being', '# modified.', '', 'Signed-off-by: %s <%s>' % (usercfg.get('name', ''), usercfg.get('email', '')), '', '', ) 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 prefixes = list() if cmdargs.set_prefixes: prefixes = list(cmdargs.set_prefixes) else: config = b4.get_main_config() if config.get('send-prefixes'): prefixes = config.get('send-prefixes').split() tracking = { 'series': { 'revision': revision, 'change-id': changeid, 'prefixes': prefixes, }, } if thread_msgid: tracking['series']['from-thread'] = thread_msgid if strategy != 'commit': # We only need the base-branch info when using strategies other than 'commit' tracking['series']['base-branch'] = basebranch 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.BytesIO() b4.save_git_am_mbox(patches, ifh) ambytes = ifh.getvalue() 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, usebranch: Optional[str] = None) -> Tuple[str, dict]: strategy = get_cover_strategy(usebranch) if strategy in {'commit', 'tip-commit'}: cover_commit = find_cover_commit(usebranch=usebranch) 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) # Unescape markdown headers cover = re.sub(r'^>(#.*)$', r'\1', 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(usebranch: Optional[str] = None) -> str: if usebranch: branch = usebranch else: 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(mustbe: bool = False, usebranch: Optional[str] = None) -> bool: mustmsg = 'CRITICAL: This is not a prep-managed branch.' if usebranch: mybranch = usebranch else: mybranch = b4.git_get_current_branch() if mybranch is None: # Not on any branch? if mustbe: logger.critical(mustmsg) sys.exit(1) return False strategy = get_cover_strategy(mybranch) if strategy in {'commit', 'tip-commit'}: if find_cover_commit(usebranch=mybranch) is None: if mustbe: logger.critical(mustmsg) sys.exit(1) 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 if mustbe: logger.critical(mustmsg) sys.exit(1) return False logger.critical('CRITICAL: unknown cover strategy: %s', strategy) sys.exit(1) def find_cover_commit(usebranch: Optional[str] = None) -> 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) if not usebranch: usebranch = b4.git_get_current_branch() gitargs = ['log', '--grep', MAGIC_MARKER, '-F', '--pretty=oneline', '--max-count=1', '--since=1.year', usebranch] 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) # Use COMMIT_EDITMSG name in hopes that editors autoload git commit rules with tempfile.TemporaryDirectory(prefix='b4-') as temp_dir: temp_fpath = os.path.join(temp_dir, 'COMMIT_EDITMSG') with open(temp_fpath, 'xb') as temp_cover: temp_cover.write(cover.encode()) sp = shlex.shlex(editor, posix=True) sp.whitespace_split = True cmdargs = list(sp) + [temp_fpath] logger.debug('Running %s' % ' '.join(cmdargs)) sp = subprocess.Popen(cmdargs) sp.wait() with open(temp_fpath, 'rb') as temp_cover: 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(usebranch: Optional[str] = None) -> str: if usebranch: mybranch = usebranch else: mybranch = b4.git_get_current_branch() strategy = get_cover_strategy(usebranch=mybranch) forkpoint = None if strategy == 'commit': # Easy, we start at the cover letter commit return find_cover_commit(usebranch=mybranch) if strategy == 'branch-description': 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(usebranch=mybranch) 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: if not b4.can_network and not cmdargs.localmbox: logger.critical('CRITICAL: To work in offline mode you have to pass a local mailbox.') sys.exit(1) 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') if cmdargs.trailers_from: msgid = cmdargs.trailers_from else: 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 or cmdargs.trailers_from: if cmdargs.trailers_from: # Compatibility with b4 overall retrieval tools cmdargs.msgid = cmdargs.trailers_from 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 if prevcommit is None: prevcommit = end 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 commits, this may take a moment...') 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, charset = b4.LoreMessage.get_payload(msg) patchid = b4.LoreMessage.get_patch_id(body) ls = b4.LoreSubject(msg.get('subject')) by_subject[ls.subject] = commit by_patchid[patchid] = commit list_msgs = list() if changeid and b4.can_network: 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 or cmdargs.localmbox: if msgid: cmdargs.msgid = msgid try: msgid, tmsgs = b4.retrieve_messages(cmdargs) except LookupError as ex: logger.critical('CRITICAL: %s', ex) sys.exit(1) 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() if lmsg.followup_trailers: addtrailers += list(lmsg.followup_trailers) if lser.has_cover and lser.patches[0].followup_trailers: addtrailers += list(lser.patches[0].followup_trailers) if not addtrailers: logger.debug('No new follow-up trailers to add 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 mbody, mcharset = b4.LoreMessage.get_payload(commit_map[commit]) parts = b4.LoreMessage.get_body_parts(mbody) 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]]: if not cmdargs: return list() # 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(errors='ignore')) raise RuntimeError('Running command failed: %s' % ' '.join(cmdargs)) addrs = out.strip().decode(errors='ignore') if not addrs: return list() return utils.getaddresses(addrs.split('\n')) def get_series_details(start_commit: Optional[str] = None, usebranch: Optional[str] = None ) -> Tuple[str, str, str, List[str], str, str]: if usebranch: mybranch = usebranch else: mybranch = b4.git_get_current_branch() if not start_commit: start_commit = get_series_start(usebranch=mybranch) strategy = get_cover_strategy(usebranch=mybranch) if strategy == 'commit': gitargs = ['rev-parse', f'{start_commit}~1'] lines = b4.git_get_command_lines(None, gitargs) base_commit = lines[0] else: base_commit = start_commit if strategy == 'tip-commit': cover_commit = find_cover_commit(usebranch=mybranch) endrange = b4.git_revparse_obj(f'{cover_commit}~1') else: endrange = b4.git_revparse_obj(mybranch) 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) gitargs = ['log', '--oneline', f'{start_commit}..{endrange}'] ecode, oneline = b4.git_run_command(None, gitargs) oneline = oneline.rstrip().splitlines() return base_commit, start_commit, endrange, oneline, 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 %s', ' ' * len(hdrname), b4.format_addrs([addr])) def get_base_changeid_from_tag(tagname: str) -> Tuple[str, str, str]: 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] return cover, base_commit, change_id 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: # Just use "b4" for the domain name (it doesn't need to be anything real) domain = 'b4' chunks = change_id.rsplit('-', maxsplit=1) stablepart = chunks[0] # Replace the change-id origin date with current date chunks = stablepart.split('-', maxsplit=1) if len(chunks) == 2 and len(chunks[0]) == 8: # If someone uses b4 in year 10000, look me up. stablepart = '%s-%s' % (datetime.date.today().strftime('%Y%m%d'), chunks[1]) # 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_cover_dests(cbody: str) -> Tuple[List, List, str]: htrs, cmsg, mtrs, basement, sig = b4.LoreMessage.get_body_parts(cbody) tos = list() ccs = list() for mtr in list(mtrs): if mtr.lname == 'to': tos.append(mtr.addr) mtrs.remove(mtr) elif mtr.lname == 'cc': ccs.append(mtr.addr) mtrs.remove(mtr) cbody = b4.LoreMessage.rebuild_message(htrs, cmsg, mtrs, basement, sig) return tos, ccs, cbody def add_cover(csubject: b4.LoreSubject, msgid_tpt: str, patches: List[Tuple[str, email.message.Message]], cbody: str, datets: int, thread: bool = True): fp = patches[0][1] cmsg = email.message.EmailMessage() cmsg.add_header('From', fp['From']) fpls = b4.LoreSubject(fp['Subject']) csubject.expected = fpls.expected csubject.counter = 0 csubject.revision = fpls.revision cmsg.add_header('Subject', csubject.get_rebuilt_subject(eprefixes=fpls.get_extra_prefixes())) cmsg.add_header('Date', email.utils.formatdate(datets, localtime=True)) cmsg.add_header('Message-Id', msgid_tpt % str(0)) cmsg.set_payload(cbody, charset='utf-8') cmsg.set_charset('utf-8') patches.insert(0, ('', cmsg)) if thread: rethread(patches) def mixin_cover(cbody: str, patches: List[Tuple[str, email.message.Message]]) -> None: msg = patches[0][1] pbody, pcharset = b4.LoreMessage.get_payload(msg) pheaders, pmessage, ptrailers, pbasement, psignature = b4.LoreMessage.get_body_parts(pbody) cheaders, cmessage, ctrailers, cbasement, csignature = b4.LoreMessage.get_body_parts(cbody) nbparts = list() nmessage = cmessage.rstrip('\r\n') + '\n' for ctr in list(ctrailers): # We hide any trailers already present in the patch itself, # or To:/Cc: trailers, which we parse elsewhere if ctr in ptrailers or ctr.lname in ('to', 'cc'): ctrailers.remove(ctr) if ctrailers: if nmessage: nmessage += '\n' for ctr in ctrailers: nmessage += ctr.as_string() + '\n' if len(nmessage.strip()): nbparts.append(nmessage) # Find the section with changelogs utility = None for section in re.split(r'^---\n', cbasement, flags=re.M): if re.search(b4.DIFFSTAT_RE, section): # Skip this section continue if re.search(r'^change-id: ', section, flags=re.I | re.M): # We move this to the bottom utility = section continue nbparts.append(section.strip('\r\n') + '\n') nbparts.append(pbasement.rstrip('\r\n') + '\n\n') if utility: nbparts.append(utility) newbasement = '---\n'.join(nbparts) pbody = b4.LoreMessage.rebuild_message(pheaders, pmessage, ptrailers, newbasement, csignature) msg.set_payload(pbody, charset='utf-8') # Check if the new body now has 8bit content and fix CTR if msg.get('Content-Transfer-Encoding') != '8bit' and not pbody.isascii(): msg.replace_header('Content-Transfer-Encoding', '8bit') def get_cover_subject_body(cover: str) -> Tuple[b4.LoreSubject, str]: clines = cover.splitlines() if len(clines) < 2 or len(clines[1].strip()) or not len(clines[0].strip()): csubject = '(no cover subject)' cbody = cover.strip() else: csubject = clines[0] cbody = '\n'.join(clines[2:]).strip() lsubject = b4.LoreSubject(csubject) return lsubject, cbody def rethread(patches: List[Tuple[str, email.message.Message]]): refto = patches[0][1].get('message-id') for commit, msg in patches[1:]: msg.add_header('References', refto) msg.add_header('In-Reply-To', refto) def get_mailfrom() -> Tuple[str, str]: sconfig = b4.get_sendemail_config() fromaddr = sconfig.get('from') if fromaddr: return email.utils.parseaddr(fromaddr) usercfg = b4.get_user_config() return usercfg.get('name'), usercfg.get('email') def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtracking: bool = True, prefixes: Optional[List[str]] = None ) -> Tuple[List, List, str, List[Tuple[str, email.message.Message]]]: cover, tracking = load_cover(strip_comments=True) if prefixes is None: prefixes = list() prefixes += tracking['series'].get('prefixes', list()) start_commit = get_series_start() change_id = tracking['series'].get('change-id') revision = tracking['series'].get('revision') msgid_tpt = make_msgid_tpt(change_id, revision) seriests = int(time.time()) mailfrom = None if movefrom: mailfrom = get_mailfrom() 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} csubject, cbody = get_cover_subject_body(cover) for cprefix in csubject.get_extra_prefixes(exclude=prefixes): prefixes.append(cprefix) patches = b4.git_range_to_patches(None, start_commit, 'HEAD', revision=revision, prefixes=prefixes, msgid_tpt=msgid_tpt, seriests=seriests, mailfrom=mailfrom, ignore_commits=ignore_commits) base_commit, stc, endc, oneline, shortlog, diffstat = get_series_details(start_commit=start_commit) 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 tptvals = { 'cover': cbody, 'shortlog': shortlog, 'diffstat': diffstat, 'change_id': change_id, 'base_commit': base_commit, 'signature': b4.get_email_signature(), } cover_letter = Template(cover_template.lstrip()).safe_substitute(tptvals) # 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).decode() # A little trick for pretty wrapping wrapped = textwrap.wrap('X-B4-Tracking: v=1; b=' + b64tracking, subsequent_indent=' ', width=75) thdata = ''.join(wrapped).replace('X-B4-Tracking: ', '') alltos, allccs, cbody = get_cover_dests(cover_letter) if len(patches) == 1: mixin_cover(cbody, patches) else: add_cover(csubject, msgid_tpt, patches, cbody, seriests, thread=thread) if addtracking: patches[0][1].add_header('X-B4-Tracking', thdata) samethread = config.get('send-same-thread', '').lower() in {'yes', 'true', 'y'} if samethread and revision > 1: oldrev = revision - 1 voldrev = f'v{oldrev}' try: oldmsgid = tracking['series']['history'][voldrev][-1] patches[0][1].add_header('In-Reply-To', f'<{oldmsgid}>') patches[0][1].add_header('References', f'<{oldmsgid}>') except (KeyError, IndexError): logger.debug('Could not find previous series msgid, skipping %s', voldrev) header = csubject.full_subject if prefixes: header = '[' + ', '.join(prefixes) + f'] {header}' tag_msg = f'{header}\n\n{cover_letter}' return alltos, allccs, tag_msg, patches def get_sent_tag_as_patches(tagname: str, revision: int) -> Tuple[List, List, List[Tuple[str, email.message.Message]]]: cover, base_commit, change_id = get_base_changeid_from_tag(tagname) csubject, cbody = get_cover_subject_body(cover) cbody = cbody.strip() + '\n-- \n' + b4.get_email_signature() prefixes = ['RESEND'] + csubject.get_extra_prefixes(exclude=['RESEND']) msgid_tpt = make_msgid_tpt(change_id, str(revision)) seriests = int(time.time()) mailfrom = get_mailfrom() patches = b4.git_range_to_patches(None, base_commit, tagname, revision=revision, prefixes=prefixes, msgid_tpt=msgid_tpt, seriests=seriests, mailfrom=mailfrom) alltos, allccs, cbody = get_cover_dests(cbody) if len(patches) == 1: mixin_cover(cbody, patches) else: add_cover(csubject, msgid_tpt, patches, cbody, seriests) return alltos, allccs, patches def format_patch(output_dir: str) -> None: try: tos, ccs, tstr, patches = get_prep_branch_as_patches(thread=False, movefrom=False, addtracking=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), 'wb') as fh: fh.write(msg.as_bytes(unixfrom=True, policy=b4.emlpolicy)) 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 mybranch = b4.git_get_current_branch() config = b4.get_main_config() tag_msg = None cl_msgid = None cover, tracking = load_cover(strip_comments=True) if cmdargs.resend: if cmdargs.resend == 'latest': revstr = tracking['series']['revision'] - 1 else: revstr = cmdargs.resend # Start with full change-id based tag name tagname, revision = get_sent_tagname(tracking['series']['change-id'], SENT_TAG_PREFIX, revstr) if revision is None: logger.critical('Could not figure out revision from %s', revstr) sys.exit(1) if not b4.git_revparse_tag(None, tagname): # Try initial branch-name only based version tagname, revision = get_sent_tagname(mybranch, SENT_TAG_PREFIX, revstr) try: todests, ccdests, patches = get_sent_tag_as_patches(tagname, revision=revision) except RuntimeError as ex: logger.critical('CRITICAL: Failed to convert tag to patches: %s', ex) sys.exit(1) logger.info('Converted the tag to %s messages', len(patches)) else: 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.preview_to: prefixes = ['PREVIEW'] else: prefixes = None try: todests, ccdests, tag_msg, 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) # Check if "EDITME" shows up in the first message if b'EDITME' in b4.LoreMessage.get_msg_as_bytes(patches[0][1]): logger.critical('CRITICAL: Looks like the cover letter needs to be edited first.') logger.info('---') logger.info(cover) logger.info('---') sys.exit(1) logger.info('Converted the branch to %s messages', len(patches)) usercfg = b4.get_user_config() myemail = usercfg.get('email') seen = set() excludes = set() pccs = dict() if cmdargs.preview_to or cmdargs.no_trailer_to_cc: todests = list() ccdests = list() else: seen.update([x[1] for x in todests]) seen.update([x[1] for x in ccdests]) # Go through the messages to make to/cc headers for commit, msg in patches: if not msg: continue body, charset = b4.LoreMessage.get_payload(msg) btrs, junk = b4.LoreMessage.find_trailers(body) for btr in btrs: if btr.type != 'person': continue if btr.addr[1] in seen: continue if commit: if commit not in pccs: pccs[commit] = list() if btr.addr not in pccs[commit]: pccs[commit].append(btr.addr) continue seen.add(btr.addr[1]) if btr.lname == 'to': todests.append(btr.addr) continue ccdests.append(btr.addr) excludes = b4.get_excluded_addrs() if cmdargs.not_me_too: excludes.add(myemail) tos = set() ccs = set() if cmdargs.preview_to: tos.update(cmdargs.preview_to) else: if cmdargs.to: tos.update(cmdargs.to) if config.get('send-series-to'): tos.add(config.get('send-series-to')) if cmdargs.cc: ccs.update(cmdargs.cc) if config.get('send-series-cc'): ccs.add(config.get('send-series-cc')) if ccs: for pair in utils.getaddresses(list(ccs)): if pair[1] in seen: continue seen.add(pair[1]) ccdests.append(pair) if tos: for pair in utils.getaddresses(list(tos)): if pair[1] in seen: continue seen.add(pair[1]) todests.append(pair) 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(alldests): logger.critical('CRITICAL: Could not find any destination addresses') logger.critical(' try b4 prep --auto-to-cc or b4 send --to addr') sys.exit(1) 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) sconfig = b4.get_sendemail_config() endpoint = None if not sconfig.get('smtpserver') or cmdargs.send_web: endpoint = config.get('send-endpoint-web', '') if not re.search(r'^https?://', endpoint): logger.debug('Endpoint does not start with https, ignoring: %s', endpoint) endpoint = None if not endpoint: # Use the default endpoint if we are in the kernel repo topdir = b4.git_get_toplevel() if os.path.exists(os.path.join(topdir, 'Kconfig')): logger.debug('No sendemail configs found, will use the default web endpoint') endpoint = DEFAULT_ENDPOINT # Cannot currently use endpoint with --preview-to if endpoint and cmdargs.preview_to: logger.critical('CRITICAL: cannot use the web endpoint with --preview-to') sys.exit(1) # Give the user the last opportunity to bail out if not cmdargs.dryrun: 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+', ' ', b4.LoreMessage.clean_header(msg.get('Subject')))) if commit in pccs: extracc = list() for pair in pccs[commit]: if pair[1] not in seen: extracc.append(pair) if extracc: print_pretty_addrs(extracc, ' +Cc') logger.info('---') usercfg = b4.get_user_config() fromaddr = usercfg['email'] logger.info('Ready to:') if endpoint: if cmdargs.reflect: logger.info(' - send the above messages to just %s (REFLECT MODE)', fromaddr) else: logger.info(' - send the above messages to actual recipients') logger.info(' - via web endpoint: %s', endpoint) else: if sconfig.get('from'): fromaddr = sconfig.get('from') if cmdargs.reflect: logger.info(' - send the above messages to just %s (REFLECT MODE)', fromaddr) elif cmdargs.preview_to: logger.info(' - send the above messages to the recipients listed (PREVIEW MODE)') else: logger.info(' - send the above messages to actual listed recipients') logger.info(' - with envelope-from: %s', fromaddr) smtpserver = sconfig.get('smtpserver', 'localhost') if '/' in smtpserver: logger.info(' - via local command %s', smtpserver) if cmdargs.reflect and sconfig.get('b4-really-reflect-via') != smtpserver: logger.critical('---') logger.critical('CRITICAL: Cowardly refusing to reflect via %s.', smtpserver) logger.critical(' There is no guarantee that this command will do the right thing') logger.critical(' and will not send mail to actual addressees.') logger.critical('---') logger.critical('If you are ABSOLUTELY SURE that this command will do the right thing,') logger.critical('add the following to the [sendemail] section:') logger.critical('b4-really-reflect-via = %s', smtpserver) sys.exit(1) else: logger.info(' - via SMTP server %s', smtpserver) if not (cmdargs.reflect or cmdargs.resend or cmdargs.preview_to): logger.info(' - tag and reroll the series to the next revision') logger.info('') if cmdargs.reflect: logger.info('REFLECT MODE:') logger.info(' The To: and Cc: headers will be fully populated, but the only') logger.info(' address given to the mail server for actual delivery will be') logger.info(' %s', fromaddr) logger.info('') logger.info(' Addresses in To: and Cc: headers will NOT receive this series.') logger.info('') try: input('Press Enter to proceed 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 send_msgs = list() for commit, msg in patches: if not msg: continue if not cl_msgid: cl_msgid = b4.LoreMessage.get_clean_msgid(msg) myto = list(allto) mycc = list(allcc) if msg['To']: myto += email.utils.getaddresses([msg['To']]) if msg['Cc']: mycc += email.utils.getaddresses([msg['Cc']]) # extend the global cc's with per-patch cc's, if any if commit and commit in pccs: # Remove any addresses already in seen for pair in pccs[commit]: if pair[1] not in seen: mycc.append(pair) elif not commit and len(pccs): # the cover letter gets sent to folks with individual patch cc's _seen = set(seen) for _commit, _ccs in pccs.items(): for pair in _ccs: if pair[1] not in _seen: mycc.append(pair) _seen.add(pair[1]) if mycc and not myto: # Move all Cc's into To when there's no To: myto = mycc mycc = list() if myto: pto = b4.cleanup_email_addrs(myto, excludes, None) if msg['To']: msg.replace_header('To', b4.format_addrs(pto)) else: msg.add_header('To', b4.format_addrs(pto)) if mycc: pcc = b4.cleanup_email_addrs(mycc, excludes, None) if msg['Cc']: msg.replace_header('Cc', b4.format_addrs(pcc)) else: msg.add_header('Cc', b4.format_addrs(pcc)) send_msgs.append(msg) if endpoint: # Web endpoint always requires signing if not sign: logger.critical('CRITICAL: Web endpoint will be used for sending, but signing is turned off') logger.critical(' Please re-enable signing or use SMTP') sys.exit(1) try: sent = b4.send_mail(None, send_msgs, fromaddr=None, patatt_sign=True, dryrun=cmdargs.dryrun, output_dir=cmdargs.output_dir, web_endpoint=endpoint, reflect=cmdargs.reflect) except RuntimeError as ex: logger.critical('CRITICAL: %s', ex) sys.exit(1) else: try: smtp, fromaddr = b4.get_smtp(dryrun=cmdargs.dryrun) except Exception as ex: # noqa logger.critical('Failed to configure the smtp connection:') logger.critical(ex) sys.exit(1) try: sent = b4.send_mail(smtp, send_msgs, fromaddr=fromaddr, patatt_sign=sign, dryrun=cmdargs.dryrun, output_dir=cmdargs.output_dir, reflect=cmdargs.reflect) except RuntimeError as ex: logger.critical('CRITICAL: %s', ex) sys.exit(1) 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) if cmdargs.reflect: logger.info('Reflected %s messages', sent) logger.debug('Not updating cover/tracking on reflect') return logger.info('Sent %s messages', sent) if cmdargs.resend: logger.debug('Not updating cover/tracking on resend') return if cmdargs.preview_to: logger.debug('Not updating cover/tracking on --preview-to') return reroll(mybranch, tag_msg, cl_msgid) def get_sent_tagname(tagbase: str, tagprefix: str, revstr: Union[str, int]) -> Tuple[str, Optional[int]]: revision = None try: revision = int(revstr) except ValueError: matches = re.search(r'^v(\d+)$', revstr) if not matches: # assume we got a full tag name, so try to find the revision there matches = re.search(r'v(\d+)$', revstr) if matches: revision = int(matches.groups()[0]) return revstr.replace('refs/tags/', ''), revision revision = int(matches.groups()[0]) if tagbase.startswith('b4/'): return f'{tagprefix}{tagbase[3:]}-v{revision}', revision return f'{tagprefix}{tagbase}-v{revision}', revision def reroll(mybranch: str, tag_msg: str, msgid: str, tagprefix: str = SENT_TAG_PREFIX): # Remove signature chunks = tag_msg.rsplit('\n-- \n') if len(chunks) > 1: tag_msg = chunks[0] + '\n' cover, tracking = load_cover(strip_comments=True) revision = tracking['series']['revision'] change_id = tracking['series']['change-id'] tagname, revision = get_sent_tagname(change_id, tagprefix, revision) logger.debug('checking if we already have %s', tagname) if not b4.git_revparse_tag(None, tagname): try: strategy = get_cover_strategy() if strategy == 'commit': # Find out the head commit, which is the end of our range gitargs = ['rev-parse', 'HEAD'] ecode, out = b4.git_run_command(None, gitargs) end_commit = out.strip() # 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 gitargs = ['cherry-pick', f'{cover_commit}..{end_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=tag_msg.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) 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(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: is_prep_branch(mustbe=True) 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 write_to_tar(bio_tar: tarfile.TarFile, name, mtime, bio_file: io.BytesIO): tifo = tarfile.TarInfo(name) tuser = os.getlogin() tuid = os.getuid() tgid = os.getgid() tifo.uid = tuid tifo.gid = tgid tifo.uname = tuser tifo.gname = tuser tifo.mtime = mtime tifo.size = bio_file.tell() bio_file.seek(0) bio_tar.addfile(tifo, bio_file) def cleanup(param: str) -> None: if param == '_show': # Show all b4-tracked branches lines = b4.git_get_command_lines(None, ['show-ref', '--heads']) if not lines: logger.critical('Git show-ref returned no heads') sys.exit(1) mybranches = list() for line in lines: parts = line.split(maxsplit=1) if parts[1].startswith('refs/heads/b4/'): mybranches.append(parts[1].replace('refs/heads/', '')) if not len(mybranches): logger.info('No b4-tracked branches found') sys.exit(0) logger.info('Please specify branch:') for branch in mybranches: logger.info(' %s', branch) return mybranch = param if not b4.git_branch_exists(None, mybranch): logger.critical('Not a known branch: %s', mybranch) sys.exit(1) is_prep_branch(mustbe=True, usebranch=mybranch) base_commit, start_commit, end_commit, oneline, shortlog, diffstat = get_series_details(usebranch=mybranch) # start commit and end commit can't be the same if start_commit == end_commit: logger.critical('CRITICAL: %s appears to be an empty branch', mybranch) sys.exit(1) # Refuse to clean up the currently checked out branch curbranch = b4.git_get_current_branch() if curbranch == mybranch: logger.critical('CRITICAL: %s is currently checked out, cannot cleanup', mybranch) sys.exit(1) cover, tracking = load_cover(usebranch=mybranch) # Find all tags ts = tracking['series'] tags = list() logger.info('Will archive and delete all of the following:') logger.info('---') logger.info('branch: %s', mybranch) if 'history' in ts: for rn, links in ts['history'].items(): tagname, revision = get_sent_tagname(ts.get('change-id'), SENT_TAG_PREFIX, rn) tag_commit = b4.git_revparse_tag(None, tagname) if not tag_commit: tagname, revision = get_sent_tagname(mybranch, SENT_TAG_PREFIX, rn) tag_commit = b4.git_revparse_tag(None, tagname) if not tag_commit: logger.debug('No tag matching revision %s', revision) continue try: cover, base_commit, change_id = get_base_changeid_from_tag(tagname) except RuntimeError as ex: logger.debug('Could not get base-commit info from %s: %s', tagname, ex) continue logger.info(' tag: %s', tagname) tags.append((tagname, base_commit, tag_commit, revision, cover)) logger.info('---') try: input('Press Enter to confirm or Ctrl-C to abort') except KeyboardInterrupt: logger.info('') sys.exit(130) tio = io.BytesIO() change_id = ts.get('change-id') deletes = list() with tarfile.open(fileobj=tio, mode='w:gz') as tfh: mnow = int(time.time()) # Add cover ifh = io.BytesIO() ifh.write(cover.encode()) write_to_tar(tfh, f'{change_id}/cover.txt', mnow, ifh) ifh.close() # Add tracking ifh = io.BytesIO() ifh.write(make_magic_json(tracking).encode()) write_to_tar(tfh, f'{change_id}/tracking.js', mnow, ifh) ifh.close() # Add the current series logger.info('Archiving branch %s', mybranch) patches = b4.git_range_to_patches(None, start_commit, end_commit) ifh = io.BytesIO() b4.save_git_am_mbox([patch[1] for patch in patches], ifh) write_to_tar(tfh, f'{change_id}/patches.mbx', mnow, ifh) ifh.close() deletes.append(['branch', '--delete', '--force', mybranch]) for tagname, base_commit, tag_commit, revision, cover in tags: logger.info('Archiving %s', tagname) # use tag date as mtime lines = b4.git_get_command_lines(None, ['log', '-1', '--format=%ct', tagname]) if not lines: logger.critical('Could not get tag date for %s', tagname) sys.exit(1) mtime = int(lines[0]) ifh = io.BytesIO() ifh.write(cover.encode()) write_to_tar(tfh, f'{change_id}/{SENT_TAG_PREFIX}patches-v{revision}.cover', mtime, ifh) ifh.close() patches = b4.git_range_to_patches(None, base_commit, tag_commit) ifh = io.BytesIO() b4.save_git_am_mbox([patch[1] for patch in patches], ifh) write_to_tar(tfh, f'{change_id}/{SENT_TAG_PREFIX}patches-v{revision}.mbx', mtime, ifh) deletes.append(['tag', '--delete', tagname]) # Write in data_dir datadir = b4.get_data_dir() archpath = os.path.join(datadir, 'prep-archived') pathlib.Path(archpath).mkdir(parents=True, exist_ok=True) tarpath = os.path.join(archpath, f'{change_id}.tar.gz') logger.info('Writing %s', tarpath) with open(tarpath, mode='wb') as tout: tout.write(tio.getvalue()) logger.info('Cleaning up git refs') for gitargs in deletes: b4.git_run_command(None, gitargs) logger.info('---') logger.info('Wrote: %s', tarpath) def show_info(param: str) -> None: # is param a name of the branch? if ':' in param: chunks = param.split(':') if len(chunks[0]): if b4.git_branch_exists(None, chunks[0]): mybranch = chunks[0] elif b4.git_branch_exists(None, f'b4/{chunks[0]}'): mybranch = f'b4/{chunks[0]}' else: logger.critical('No such branch: %s', chunks[0]) sys.exit(1) else: mybranch = b4.git_get_current_branch() if not len(chunks[1]): getval = '_all' else: getval = chunks[1] elif b4.git_branch_exists(None, param): mybranch = param getval = '_all' else: mybranch = b4.git_get_current_branch() getval = param is_prep_branch(mustbe=True, usebranch=mybranch) info = dict() info['branch'] = mybranch cover, tracking = load_cover(usebranch=mybranch) csubject, cbody = get_cover_subject_body(cover) info['cover-subject'] = csubject.full_subject info['needs-editing'] = 'EDITME' in cover ts = tracking['series'] if ts.get('prefixes'): info['prefixes'] = ' '.join(ts.get('prefixes')) info['change-id'] = ts.get('change-id') revision = ts.get('revision') info['revision'] = revision strategy = get_cover_strategy(usebranch=mybranch) info['cover-strategy'] = strategy if ts.get('base-branch'): info['base-branch'] = ts['base-branch'] base_commit, start_commit, end_commit, oneline, shortlog, diffstat = get_series_details(usebranch=mybranch) info['base-commit'] = base_commit info['start-commit'] = start_commit info['end-commit'] = end_commit info['series-range'] = f'{start_commit}..{end_commit}' for line in oneline: short, subject = line.split(maxsplit=1) info[f'commit-{short}'] = subject if 'history' in ts: for rn, links in reversed(ts['history'].items()): tagname, revision = get_sent_tagname(ts.get('change-id'), SENT_TAG_PREFIX, rn) tag_commit = b4.git_revparse_tag(None, tagname) if not tag_commit: logger.debug('No tag %s, trying with base branch name %s', tagname, mybranch) tagname, revision = get_sent_tagname(mybranch, SENT_TAG_PREFIX, rn) tag_commit = b4.git_revparse_tag(None, tagname) if not tag_commit: logger.debug('No tag matching revision %s', revision) continue try: cover, base_commit, change_id = get_base_changeid_from_tag(tagname) info[f'series-{rn}'] = '%s..%s %s' % (base_commit[:12], tag_commit[:12], links[0]) except RuntimeError as ex: logger.debug('Could not get base-commit info from %s: %s', tagname, ex) if getval == '_all': for key, val in info.items(): print('%s: %s' % (key, val)) elif getval in info: print(info[getval]) else: logger.critical('No info about %s', getval) sys.exit(1) 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 compare(compareto: str) -> None: cover, tracking = load_cover() # Try the new format first tagname, revision = get_sent_tagname(tracking['series']['change-id'], SENT_TAG_PREFIX, compareto) prev_end = b4.git_revparse_tag(None, tagname) if not prev_end: mybranch = b4.git_get_current_branch(None) tagname, revision = get_sent_tagname(mybranch, SENT_TAG_PREFIX, compareto) prev_end = b4.git_revparse_tag(None, tagname) if not prev_end: logger.critical('CRITICAL: Could not rev-parse %s', tagname) sys.exit(1) try: cover, base_commit, change_id = get_base_changeid_from_tag(tagname) except RuntimeError as ex: logger.critical('CRITICAL: %s', str(ex)) sys.exit(1) prev_start = base_commit curr_start = get_series_start() strategy = get_cover_strategy() if strategy == 'tip-commit': cover_commit = find_cover_commit() series_end = f'{cover_commit}~1' else: series_end = 'HEAD' gitargs = ['rev-parse', series_end] lines = b4.git_get_command_lines(None, gitargs) curr_end = lines[0] grdcmd = ['git', 'range-diff', '%.12s..%.12s' % (prev_start, prev_end), '%.12s..%.12s' % (curr_start, curr_end)] # We exec range-diff and let it take over logger.debug('Running %s', ' '.join(grdcmd)) os.execvp(grdcmd[0], grdcmd) def auto_to_cc() -> None: tocmdstr = None cccmdstr = None topdir = b4.git_get_toplevel() # Use sane tocmd and cccmd defaults if we find a get_maintainer.pl getm = os.path.join(topdir, 'scripts', 'get_maintainer.pl') config = b4.get_main_config() if config.get('send-auto-to-cmd'): tocmdstr = config.get('send-auto-to-cmd') elif os.access(getm, os.X_OK): 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): cccmdstr = f'{getm} --nogit --nogit-fallback --nogit-chief-penguins --norolestats --nom' tocmd = list() cccmd = list() if tocmdstr: sp = shlex.shlex(tocmdstr, posix=True) sp.whitespace_split = True tocmd = list(sp) logger.info('Will collect To: addresses using %s', os.path.basename(tocmd[0])) if cccmdstr: sp = shlex.shlex(cccmdstr, posix=True) sp.whitespace_split = True cccmd = list(sp) logger.info('Will collect Cc: addresses using %s', os.path.basename(cccmd[0])) logger.debug('Getting addresses from cover letter') cover, tracking = load_cover(strip_comments=False) parts = b4.LoreMessage.get_body_parts(cover) seen = set() for ltr in parts[2]: if not ltr.addr: continue seen.add(ltr.addr[1]) logger.debug('added %s to seen', ltr.addr[1]) extras = list() for tname, addrs in (('To', config.get('send-series-to')), ('Cc', config.get('send-series-cc'))): if not addrs: continue for pair in utils.getaddresses([addrs]): if pair[1] in seen: continue seen.add(pair[1]) ltr = b4.LoreTrailer(name=tname, value=b4.format_addrs([pair])) logger.debug('added %s to seen', ltr.addr[1]) extras.append(ltr) try: tos, ccs, tag_msg, patches = get_prep_branch_as_patches() except RuntimeError: logger.info('No commits in branch') return logger.info('Collecting To/Cc addresses') # Go through the messages to make to/cc headers for commit, msg in patches: if not msg or not commit: continue logger.debug('Collecting from: %s', msg.get('subject')) msgbytes = msg.as_bytes() for tname, pairs in (('To', get_addresses_from_cmd(tocmd, msgbytes)), ('Cc', get_addresses_from_cmd(cccmd, msgbytes))): for pair in pairs: if pair[1] not in seen: seen.add(pair[1]) ltr = b4.LoreTrailer(name=tname, value=b4.format_addrs([pair])) logger.debug(' => %s', ltr.as_string()) extras.append(ltr) if not extras: logger.info('No new addresses to add.') return # Make it a LoreMessage, so we can run a fix_trailers on it cmsg = email.message.EmailMessage() cmsg.set_payload(cover, charset='utf-8') clm = b4.LoreMessage(cmsg) fallback_order = config.get('send-trailer-order', 'To,Cc,*') clm.fix_trailers(extras=extras, fallback_order=fallback_order) logger.info('---') logger.info('You can trim/expand this list with: b4 prep --edit-cover') store_cover(clm.body, tracking) def set_prefixes(prefixes: list) -> None: cover, tracking = load_cover() old_prefixes = tracking['series'].get('prefixes', list()) if len(prefixes) == 1 and not prefixes[0].strip(): prefixes = list() tracking['series']['prefixes'] = prefixes if tracking['series']['prefixes'] != old_prefixes: store_cover(cover, tracking) if tracking['series']['prefixes']: logger.info('Updated extra prefixes to: %s', ' '.join(prefixes)) else: logger.info('Removed all extra prefixes.') else: logger.info('No changes to extra prefixes.') 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.reroll: msgid = cmdargs.reroll msgs = b4.get_pi_thread_by_msgid(msgid, onlymsgids={msgid}, nocache=True) mybranch = b4.git_get_current_branch(None) if msgs: for msg in msgs: if b4.LoreMessage.get_clean_msgid(msg) == msgid: # Prepare annotated tag body from the cover letter lsubject = b4.LoreSubject(msg.get('subject')) cbody, charset = b4.LoreMessage.get_payload(msg) prefixes = lsubject.get_extra_prefixes() if prefixes: subject = '[%s] %s' % (' '.join(prefixes), lsubject.subject) else: subject = lsubject.subject tag_msg = subject + '\n\n' + cbody return reroll(mybranch, tag_msg, msgid) logger.critical('CRITICAL: could not retrieve %s', msgid) sys.exit(1) if cmdargs.show_revision: return show_revision() if cmdargs.show_info: return show_info(cmdargs.show_info) if cmdargs.cleanup: return cleanup(cmdargs.cleanup) if cmdargs.format_patch: return format_patch(cmdargs.format_patch) if cmdargs.compare_to: return compare(cmdargs.compare_to) if cmdargs.enroll_base and cmdargs.new_series_name: logger.critical('CRITICAL: -n NEW_SERIES_NAME and -e [ENROLL_BASE] can not be used together.') sys.exit(1) if cmdargs.enroll_base or cmdargs.new_series_name: if is_prep_branch() and not cmdargs.fork_point: logger.critical('CRITICAL: This appears to already be a b4-prep managed branch.') sys.exit(1) start_new_series(cmdargs) if cmdargs.force_revision: force_revision(cmdargs.force_revision) if cmdargs.set_prefixes: set_prefixes(cmdargs.set_prefixes) if cmdargs.auto_to_cc: auto_to_cc() if cmdargs.edit_cover: return edit_cover() 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) b4-0.13.0/b4/kr.py000066400000000000000000000062411456345453500134140ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2020-2021 by the Linux Foundation # __author__ = 'Konstantin Ryabitsev ' import os import sys import pathlib import re import b4 import b4.mbox logger = b4.logger def main(cmdargs): if cmdargs.showkeys: msgid, msgs = b4.retrieve_messages(cmdargs) logger.info('---') try: import patatt except ModuleNotFoundError: logger.info('--show-keys requires the patatt library') sys.exit(1) keydata = set() for msg in msgs: xdk = msg.get('x-developer-key') xds = msg.get('x-developer-signature') if not xdk or not xds: continue # grab the selector they used kdata = b4.LoreMessage.get_parts_from_header(xdk) sdata = b4.LoreMessage.get_parts_from_header(xds) algo = kdata.get('a') identity = kdata.get('i') selector = sdata.get('s', 'default') if algo == 'openpgp': keyinfo = kdata.get('fpr') elif algo == 'ed25519': keyinfo = kdata.get('pk') else: logger.debug('Unknown key type: %s', algo) continue keydata.add((identity, algo, selector, keyinfo)) if not keydata: logger.info('No keys found in the thread.') sys.exit(0) krpath = os.path.join(b4.get_data_dir(), 'keyring') pgp = False ecc = False for identity, algo, selector, keyinfo in keydata: keypath = patatt.make_pkey_path(algo, identity, selector) fullpath = os.path.join(krpath, keypath) if os.path.exists(fullpath): status = 'known' else: status = 'unknown' if algo == 'openpgp': try: uids = b4.get_gpg_uids(keyinfo) if len(uids): status = 'in default keyring' except KeyError: pass pathlib.Path(os.path.dirname(fullpath)).mkdir(parents=True, exist_ok=True) logger.info('%s: (%s)', identity, status) logger.info(' keytype: %s', algo) if algo == 'openpgp': pgp = True logger.info(' keyid: %s', keyinfo[-16:]) logger.info(' fpr: %s', ':'.join(re.findall(r'.{4}', keyinfo))) else: ecc = True logger.info(' pubkey: %s', keyinfo) logger.info(' krpath: %s', keypath) logger.info(' fullpath: %s', fullpath) logger.info('---') if pgp: logger.info('For openpgp keys:') logger.info(' gpg --recv-key [keyid]') logger.info(' gpg -a --export [keyid] > [fullpath]') if ecc: logger.info('For ed25519 keys:') logger.info(' echo [pubkey] > [fullpath]') sys.exit(0) logger.info('This command is experimental. Try --show-keys [msgid].') b4-0.13.0/b4/mbox.py000066400000000000000000000710211456345453500137430ustar00rootroot00000000000000#!/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 os import sys import mailbox import email import email.message import email.utils import re import time import json import fnmatch import shutil import pathlib import io import shlex import argparse import b4 from typing import Optional, List 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: List[email.message.Message], cmdargs: argparse.Namespace, msgid: str) -> None: config = b4.get_main_config() outdir = cmdargs.outdir if outdir == '-': cmdargs.nocover = True wantver = cmdargs.wantver count = len(msgs) logger.info('Analyzing %s messages in the thread', count) lmbx = b4.LoreMailbox() # Go through the mbox once to populate base series load_codereview = True for msg in msgs: # Is it a collection of patches attached to the same message? # We only trigger this mode with --single-msg if msg.is_multipart() and ('singlemsg' in cmdargs and cmdargs.singlemsg): xpatches = list() xmsgid = b4.LoreMessage.get_clean_msgid(msg) for part in msg.walk(): cte = part.get_content_type() if cte.find('/x-patch') < 0: continue payload = part.get_payload(decode=True) if payload is None: continue pcharset = part.get_content_charset() if not pcharset: pcharset = 'utf-8' try: payload = payload.decode(pcharset, errors='replace') except LookupError: # what kind of encoding is that? # Whatever, we'll use utf-8 and hope for the best payload = payload.decode('utf-8', errors='replace') part.set_param('charset', 'utf-8') if payload and b4.DIFF_RE.search(payload): xmsg = email.message_from_string(payload, policy=b4.emlpolicy) # Needs to have Subject, From, Date for us to consider it if xmsg.get('Subject') and xmsg.get('From') and xmsg.get('Date'): logger.debug('Found attached patch: %s', xmsg.get('Subject')) xmsg['Message-ID'] = f'' xpatches.append(xmsg) if len(xpatches): logger.info('Warning: Found %s patches attached to the requested message', len(xpatches)) logger.info(' This mode ignores any follow-up trailers, use with caution') # Throw out lmbx and only use these lmbx = b4.LoreMailbox() load_codereview = False for xmsg in xpatches: lmbx.add_message(xmsg) # Make a cover letter out of the original message cmsg = email.message.EmailMessage() cbody, ccharset = b4.LoreMessage.get_payload(msg, use_patch=False) cmsg['From'] = msg.get('From') cmsg['Date'] = msg.get('Date') cmsg['Message-ID'] = msg.get('Message-ID') cmsg['Subject'] = '[PATCH 0/0] ' + msg.get('Subject') cmsg.set_payload(cbody, ccharset) lmbx.add_message(cmsg) break else: lmbx.add_message(msg) reroll = True if cmdargs.nopartialreroll: reroll = False lser = lmbx.get_series(revision=wantver, sloppytrailers=cmdargs.sloppytrailers, reroll=reroll, codereview_trailers=load_codereview) if lser is None and cmdargs.cherrypick != '_': if wantver is None: logger.critical('No patches found.') else: logger.critical('Unable to find revision %s', wantver) return if len(lmbx.series) > 1 and not wantver: logger.info('Will use the latest revision: v%s', lser.revision) logger.info('You can pick other revisions using the -vN flag') if cmdargs.cherrypick: cherrypick = list() if cmdargs.cherrypick == '_': # We might want to pick a patch sent as a followup, so create a fake series # and add followups with diffs if lser is None: lser = b4.LoreSeries(revision=1, expected=1) for followup in lmbx.followups: if followup.has_diff: lser.add_patch(followup) # Only grab the exact msgid provided at = 0 for lmsg in lser.patches[1:]: at += 1 if lmsg and lmsg.msgid == msgid: cherrypick = [at] cmdargs.cherrypick = f'<{msgid}>' break if not len(cherrypick): logger.critical('Specified msgid is not present in the series, cannot cherrypick') sys.exit(1) elif cmdargs.cherrypick.find('*') >= 0: # Globbing on subject at = 0 for lmsg in lser.patches[1:]: at += 1 if fnmatch.fnmatch(lmsg.subject, cmdargs.cherrypick): cherrypick.append(at) if not len(cherrypick): logger.critical('Could not match "%s" to any subjects in the series', cmdargs.cherrypick) sys.exit(1) else: cherrypick = list(b4.parse_int_range(cmdargs.cherrypick, upper=len(lser.patches) - 1)) else: cherrypick = None am_msgs = lser.get_am_ready(noaddtrailers=cmdargs.noaddtrailers, addmysob=cmdargs.addmysob, addlink=cmdargs.addlink, cherrypick=cherrypick, copyccs=cmdargs.copyccs, allowbadchars=cmdargs.allowbadchars) logger.info('---') if cherrypick is None: logger.critical('Total patches: %s', len(am_msgs)) else: logger.info('Total patches: %s (cherrypicked: %s)', len(am_msgs), cmdargs.cherrypick) if len(lser.trailer_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') 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: if not topdir: logger.critical('WARNING: cannot prepare 3-way (not in a git dir)') elif not lser.complete: logger.critical('WARNING: cannot prepare 3-way (series incomplete)') else: rstart, rend = lser.make_fake_am_range(gitdir=None) if rstart and rend: logger.info('Preared a fake commit range for 3-way merge (%.12s..%.12s)', rstart, rend) logger.critical('---') if lser.partial_reroll: logger.critical('WARNING: v%s is a partial reroll from previous revisions', lser.revision) logger.critical(' Please carefully review the resulting series to ensure correctness') logger.critical(' Pass --no-partial-reroll to disable') logger.critical('---') if not lser.complete and not cmdargs.cherrypick: logger.critical('WARNING: Thread incomplete!') gitbranch = lser.get_slug(extended=False) am_filename = None 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, 'wb') as fh: b4.save_git_am_mbox(am_msgs, fh) else: am_cover = None b4.save_git_am_mbox(am_msgs, sys.stdout.buffer) if lser.has_cover and not cmdargs.nocover: lser.save_cover(am_cover) 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: .*?([\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 .*?([\da-f]{40})', first_body, re.MULTILINE) if matches: base_commit = matches.groups()[0] if base_commit and topdir: # Does it actually exist in this tree? if not b4.git_commit_exists(topdir, base_commit): logger.info(' Base: base-commit %s not known, ignoring', base_commit) base_commit = None elif not cmdargs.mergebase: logger.info(' Base: using specified base-commit %s', 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.mergebase: if base_commit: logger.warn(' Base: overriding submitter provided base-commit %s', base_commit) base_commit = cmdargs.mergebase logger.info(' Base: using CLI provided base-commit %s', base_commit) 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.BytesIO() b4.save_git_am_mbox(am_msgs, ifh) ambytes = ifh.getvalue() 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, rundir=topdir) 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' linkurl = config['linkmask'] % top_msgid try: b4.git_fetch_am_into_repo(topdir, am_msgs=am_msgs, at_base=base_commit, origin=linkurl) except RuntimeError: sys.exit(1) gitargs = ['rev-parse', '--git-dir'] ecode, out = 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(out.rstrip(), 'b4-cover') merge_template = DEFAULT_MERGE_TEMPLATE if config.get('shazam-merge-template'): # Try to load this template instead try: 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: 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, 'mid': top_msgid, '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 if cmdargs.no_interactive: edit = '--no-edit' else: edit = '--edit' 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)) if hasattr(sys, '_running_in_pytest'): # Don't execvp, as this kills our tests out, logstr = b4.git_run_command(None, mergeargs) sys.exit(out) # 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)) sys.exit(0) 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') if base_commit is not None: logger.critical(' git checkout -b %s %s', gitbranch, base_commit) if cmdargs.outdir != '-': logger.critical(' git am %s%s', '-3 ' if cmdargs.threeway else '', am_filename) thanks_record_am(lser, cherrypick=cherrypick) def thanks_record_am(lser: b4.LoreSeries, cherrypick: bool = None) -> None: # Are we tracking this already? datadir = b4.get_data_dir() slug = lser.get_slug(extended=True) filename = '%s.am' % slug patches = list() msgids = list() at = 0 padlen = len(str(lser.expected)) lmsg = None for pmsg in lser.patches: if pmsg is None: at += 1 continue msgids.append(pmsg.msgid) if lmsg is None: lmsg = pmsg if not pmsg.has_diff: # Don't care about the cover letter at += 1 continue if cherrypick is not None and at not in cherrypick: logger.debug('Skipped non-cherrypicked: %s', at) at += 1 continue if pmsg.pwhash is None: logger.debug('Unable to get hashes for all patches, not tracking for thanks') return prefix = '%s/%s' % (str(pmsg.counter).zfill(padlen), pmsg.expected) patches.append((pmsg.subject, pmsg.pwhash, pmsg.msgid, prefix)) at += 1 if lmsg is None: logger.debug('All patches missing, not tracking for thanks') return try: allto = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('to', [])]) except Exception as ex: # noqa allto = [] logger.debug('Unable to parse the To: header in %s: %s', lmsg.msgid, str(ex)) try: allcc = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('cc', [])]) except Exception as ex: # noqa allcc = [] logger.debug('Unable to parse the Cc: header in %s: %s', lmsg.msgid, str(ex)) # TODO: check for reply-to and x-original-from out = { 'msgid': lmsg.msgid, 'subject': lmsg.full_subject, 'fromname': lmsg.fromname, 'fromemail': lmsg.fromemail, 'to': b4.format_addrs(allto, clean=False), 'cc': b4.format_addrs(allcc, clean=False), 'references': b4.LoreMessage.clean_header(lmsg.msg['References']), 'sentdate': b4.LoreMessage.clean_header(lmsg.msg['Date']), 'quote': b4.make_quote(lmsg.body, maxlines=5), 'cherrypick': cherrypick is not None, 'patches': patches, } fullpath = os.path.join(datadir, filename) with open(fullpath, 'w', encoding='utf-8') as fh: 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: List[email.message.Message], q_dirname: str) -> None: if os.path.exists(q_dirname): logger.critical('ERROR: Directory %s exists, not saving quilt patches', q_dirname) return pathlib.Path(q_dirname).mkdir(parents=True) patch_filenames = list() for msg in am_msgs: lsubj = b4.LoreSubject(msg.get('subject', '')) slug = '%04d_%s' % (lsubj.counter, re.sub(r'\W+', '_', lsubj.subject).strip('_').lower()) patch_filename = f'{slug}.patch' patch_filenames.append(patch_filename) quilt_out = os.path.join(q_dirname, patch_filename) i, m, p = b4.get_mailinfo(msg.as_bytes(policy=b4.emlpolicy), scissors=True) with open(quilt_out, 'wb') as fh: if i.get('Author'): fh.write(b'From: %s <%s>\n' % (i.get('Author').encode(), i.get('Email').encode())) else: fh.write(b'From: %s\n' % i.get('Email').encode()) fh.write(b'Subject: %s\n' % i.get('Subject').encode()) fh.write(b'Date: %s\n' % i.get('Date').encode()) fh.write(b'\n') fh.write(m) fh.write(p) logger.debug(' Wrote: %s', patch_filename) # Write the series file with open(os.path.join(q_dirname, 'series'), 'w') as sfh: for patch_filename in patch_filenames: sfh.write('%s\n' % patch_filename) def get_extra_series(msgs: list, direction: int = 1, wantvers: Optional[int] = None, nocache: bool = False) -> List[email.message.Message]: base_msg = None latest_revision = None seen_msgids = set() seen_covers = set() queries = set() for msg in msgs: msgid = b4.LoreMessage.get_clean_msgid(msg) seen_msgids.add(msgid) lsub = b4.LoreSubject(msg['Subject']) # Ignore patches above 1 if lsub.counter > 1: continue if not lsub.reply: payload, charset = b4.LoreMessage.get_payload(msg) if payload: matches = re.search(r'^change-id:\s+(\S+)', payload, flags=re.I | re.M) if matches: logger.debug('Found change-id %s', matches.groups()[0]) q = 'nq:"change-id: %s"' % matches.groups()[0] queries.add(q) if base_msg is not None: logger.debug('Current base_msg: %s', base_msg['Subject']) logger.debug('Checking the subject on %s', lsub.full_subject) if latest_revision is None or lsub.revision >= latest_revision: latest_revision = lsub.revision if lsub.counter == 0 and not lsub.counters_inferred: # And a cover letter, nice. This is the easy case base_msg = msg seen_covers.add(latest_revision) elif lsub.counter == 1 and latest_revision not in seen_covers: # A patch/series without a cover letter base_msg = msg if not queries and base_msg is None: return msgs # Get subject info from base_msg again lsub = b4.LoreSubject(base_msg['Subject']) if not len(lsub.prefixes): logger.debug('Not checking for new revisions: no prefixes on the cover letter.') return msgs if direction < 0 and latest_revision <= 1: logger.debug('This is the earliest version of the series') return msgs if direction < 0 and wantvers is None: wantvers = [latest_revision - 1] fromeml = email.utils.getaddresses(base_msg.get_all('from', []))[0][1] msgdate = email.utils.parsedate_tz(str(base_msg['Date'])) q = '(s:"%s" AND f:"%s")' % (lsub.subject.replace('"', ''), fromeml) queries.add(q) startdate = time.strftime('%Y%m%d', msgdate[:9]) if direction > 0: logger.critical('Checking for newer revisions') datelim = 'd:%s..' % startdate else: logger.critical('Checking for older revisions') datelim = 'd:..%s' % startdate q = '(%s) AND %s' % (' OR '.join(queries), datelim) q_msgs = b4.get_pi_search_results(q, nocache=nocache) if not q_msgs: return msgs seen_revisions = dict() for q_msg in q_msgs: q_msgid = b4.LoreMessage.get_clean_msgid(q_msg) lsub = b4.LoreSubject(q_msg.get('subject')) if q_msgid in seen_msgids: logger.debug('Skipping %s: already have it', lsub.full_subject) continue if lsub.reply: # These will get sorted out later logger.debug('Adding reply: %s', lsub.full_subject) msgs.append(q_msg) seen_msgids.add(q_msgid) continue if direction > 0 and lsub.revision <= latest_revision: logger.debug('Ignoring result (not new revision): %s', lsub.full_subject) continue elif direction < 0 and lsub.revision >= latest_revision: logger.debug('Ignoring result (not old revision): %s', lsub.full_subject) continue elif direction < 0 and lsub.revision not in wantvers: logger.debug('Ignoring result (not revision we want): %s', lsub.full_subject) continue if lsub.revision == 1 and lsub.revision == latest_revision: # Someone sent a separate message with an identical title but no new vX in the subject line if direction > 0: # It's *probably* a new revision. logger.debug('Likely a new revision: %s', lsub.full_subject) else: # It's *probably* an older revision. logger.debug('Likely an older revision: %s', lsub.full_subject) elif direction > 0 and lsub.revision > latest_revision: logger.debug('Definitely a new revision [v%s]: %s', lsub.revision, lsub.full_subject) elif direction < 0 and lsub.revision < latest_revision: logger.debug('Definitely an older revision [v%s]: %s', lsub.revision, lsub.full_subject) else: logger.debug('No idea what this is: %s', lsub.subject) continue if lsub.revision not in seen_revisions: seen_revisions[lsub.revision] = 0 seen_revisions[lsub.revision] += 1 logger.debug('Adding: %s', lsub.full_subject) msgs.append(q_msg) seen_msgids.add(q_msgid) for rev, count in seen_revisions.items(): logger.info(' Added from v%s: %s patches', rev, count) return msgs def is_maildir(dest: str) -> bool: if (os.path.isdir(os.path.join(dest, 'new')) and os.path.isdir(os.path.join(dest, 'cur')) and os.path.isdir(os.path.join(dest, 'tmp'))): return True return False def refetch(dest: str) -> None: if is_maildir(dest): mbox = mailbox.Maildir(dest) else: mbox = mailbox.mbox(dest) by_msgid = dict() for key, msg in mbox.items(): msgid = b4.LoreMessage.get_clean_msgid(msg) if msgid not in by_msgid: amsgs = b4.get_pi_thread_by_msgid(msgid, nocache=True) for amsg in amsgs: amsgid = b4.LoreMessage.get_clean_msgid(amsg) if amsgid not in by_msgid: by_msgid[amsgid] = amsg if msgid in by_msgid: mbox.update(((key, by_msgid[msgid]),)) logger.info('Refetched: %s', msg.get('Subject')) else: logger.warn('WARNING: Message-id not known: %s', msgid) mbox.close() def main(cmdargs: argparse.Namespace) -> None: # We force some settings if cmdargs.subcmd == 'shazam': 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 else: cmdargs.mergebase = False if cmdargs.checknewer: # Force nocache mode cmdargs.nocache = True if cmdargs.subcmd == 'mbox' and cmdargs.refetch: return refetch(cmdargs.refetch) try: msgid, msgs = b4.retrieve_messages(cmdargs) except LookupError as ex: logger.critical('CRITICAL: %s', ex) sys.exit(1) if not msgs: sys.exit(1) if len(msgs) and cmdargs.checknewer and b4.can_network: msgs = get_extra_series(msgs, direction=1, nocache=cmdargs.nocache) if cmdargs.subcmd in ('am', 'shazam'): make_am(msgs, cmdargs, msgid) return logger.info('%s messages in the thread', len(msgs)) if cmdargs.outdir == '-': logger.info('---') b4.save_mboxrd_mbox(msgs, sys.stdout.buffer, mangle_from=False) return # Check if outdir is a maildir if is_maildir(cmdargs.outdir): mdr = mailbox.Maildir(cmdargs.outdir) have_msgids = set() added = 0 if cmdargs.filterdupes: for emsg in mdr: have_msgids.add(b4.LoreMessage.get_clean_msgid(emsg)) for msg in msgs: if b4.LoreMessage.get_clean_msgid(msg) not in have_msgids: added += 1 mdr.add(msg) logger.info('Added %s messages to maildir %s', added, cmdargs.outdir) return config = b4.get_main_config() if cmdargs.maildir or config.get('save-maildirs', 'no') == 'yes': save_maildir = True dftext = 'maildir' else: save_maildir = False dftext = 'mbx' if cmdargs.wantname: savename = os.path.join(cmdargs.outdir, cmdargs.wantname) else: safe_msgid = re.sub(r'[^\w@.+%-]+', '_', msgid).strip('_') savename = os.path.join(cmdargs.outdir, f'{safe_msgid}.{dftext}') if save_maildir: if os.path.isdir(savename): shutil.rmtree(savename) md = mailbox.Maildir(savename, create=True) for msg in msgs: md.add(msg) md.close() logger.info('Saved maildir %s', savename) return with open(savename, 'wb') as fh: b4.save_mboxrd_mbox(msgs, fh, mangle_from=True) logger.info('Saved %s', savename) b4-0.13.0/b4/pr.py000066400000000000000000000444431456345453500134270ustar00rootroot00000000000000#!/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 os import sys import tempfile import b4 import re import json import email import argparse import urllib.parse import requests from datetime import datetime from email import utils, charset from typing import Optional, List charset.add_charset('utf-8', None) logger = b4.logger PULL_BODY_SINCE_ID_RE = [ 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 ([\da-f]{5,40}):', re.M | re.I), ] # I don't like these PULL_BODY_REMOTE_REF_RE = [ re.compile(r'^\s*([\w+-]+(?:://|@)[\w/.@:~-]+)[\s\\]+([\w/._-]+)\s*$', re.M | re.I), re.compile(r'^\s*([\w+-]+(?:://|@)[\w/.@~-]+)\s*$', re.M | re.I), ] def git_get_commit_id_from_repo_ref(repo: str, ref: str) -> Optional[str]: # We only handle git and http/s URLs if not (repo.find('git://') == 0 or repo.find('http://') == 0 or repo.find('https://') == 0): logger.info('%s uses unsupported protocol', repo) return None logger.debug('getting commit-id from: %s %s', repo, ref) # Drop the leading "refs/", if any ref = re.sub(r'^refs/', '', ref) # Is it a full ref name or a shortname? if ref.find('heads/') < 0 and ref.find('tags/') < 0: # Try grabbing it as a head first lines = b4.git_get_command_lines(None, ['ls-remote', repo, 'refs/heads/%s' % ref]) if not lines: # try it as a tag, then lines = b4.git_get_command_lines(None, ['ls-remote', repo, 'refs/tags/%s^{}' % ref]) elif ref.find('tags/') == 0: #try as an annotated tag first lines = b4.git_get_command_lines(None, ['ls-remote', repo, 'refs/%s^{}' % ref]) if not lines: # try it as a non-annotated tag, then lines = b4.git_get_command_lines(None, ['ls-remote', repo, 'refs/%s' % ref]) else: # Grab it as a head and hope for the best lines = b4.git_get_command_lines(None, ['ls-remote', repo, 'refs/%s' % ref]) if not lines: # Oh well, we tried logger.debug('did not find commit-id, ignoring pull request') return None commit_id = lines[0].split()[0] logger.debug('success, commit-id: %s', commit_id) return commit_id def parse_pr_data(msg: email.message.Message) -> Optional[b4.LoreMessage]: lmsg = b4.LoreMessage(msg) if lmsg.body is None: logger.critical('Could not find a plain part in the message body') return None logger.info('Looking at: %s', lmsg.full_subject) for since_re in PULL_BODY_SINCE_ID_RE: matches = since_re.search(lmsg.body) if matches: lmsg.pr_base_commit = matches.groups()[0] break for reporef_re in PULL_BODY_REMOTE_REF_RE: matches = reporef_re.search(lmsg.body) if matches: chunks = matches.groups() lmsg.pr_repo = chunks[0] if len(chunks) > 1: lmsg.pr_ref = chunks[1] else: lmsg.pr_ref = 'refs/heads/master' break for cid_re in PULL_BODY_WITH_COMMIT_ID_RE: matches = cid_re.search(lmsg.body) if matches: lmsg.pr_tip_commit = matches.groups()[0] break if lmsg.pr_repo and lmsg.pr_ref: lmsg.pr_remote_tip_commit = git_get_commit_id_from_repo_ref(lmsg.pr_repo, lmsg.pr_ref) return lmsg def attest_fetch_head(gitdir: Optional[str], lmsg: b4.LoreMessage) -> None: config = b4.get_main_config() attpolicy = config['attestation-policy'] if config['attestation-checkmarks'] == 'fancy': attpass = b4.ATT_PASS_FANCY attfail = b4.ATT_FAIL_FANCY else: attpass = b4.ATT_PASS_SIMPLE attfail = b4.ATT_FAIL_SIMPLE # Is FETCH_HEAD a tag or a commit? htype = b4.git_get_command_lines(gitdir, ['cat-file', '-t', 'FETCH_HEAD']) passing = False out = '' otype = 'unknown' if len(htype): otype = htype[0] if otype == 'tag': ecode, out = b4.git_run_command(gitdir, ['verify-tag', '--raw', 'FETCH_HEAD'], logstderr=True) elif otype == 'commit': ecode, out = b4.git_run_command(gitdir, ['verify-commit', '--raw', 'FETCH_HEAD'], logstderr=True) good, valid, trusted, keyid, sigtime = b4.check_gpg_status(out) signer = None if keyid: try: uids = b4.get_gpg_uids(keyid) for uid in uids: if uid.find(f'<{lmsg.fromemail}') >= 0: signer = uid break if not signer: signer = uids[0] except KeyError: signer = f'{lmsg.fromname} <{lmsg.fromemail}>' if good and valid: passing = True out = out.strip() errors = set() if not len(out) and attpolicy != 'check': errors.add('Remote %s is not signed!' % otype) if passing: trailer = 'Signed: %s' % signer logger.info(' ---') logger.info(' %s %s', attpass, trailer) return if errors: logger.critical(' ---') if len(out): logger.critical(' Pull request is signed, but verification did not succeed:') else: logger.critical(' Pull request verification did not succeed:') for error in errors: logger.critical(' %s %s', attfail, error) if attpolicy == 'hardfail': import sys sys.exit(128) def fetch_remote(gitdir: Optional[str], lmsg: b4.LoreMessage, branch: Optional[str] = None, check_sig: bool = True, ty_track: bool = True) -> int: # Do we know anything about this base commit? if lmsg.pr_base_commit and not b4.git_commit_exists(gitdir, lmsg.pr_base_commit): logger.critical('ERROR: git knows nothing about commit %s', lmsg.pr_base_commit) logger.critical(' Are you running inside a git checkout and is it up-to-date?') return 1 if lmsg.pr_tip_commit != lmsg.pr_remote_tip_commit: logger.critical('ERROR: commit-id mismatch between pull request and remote') logger.critical(' msg=%s, remote=%s', lmsg.pr_tip_commit, lmsg.pr_remote_tip_commit) return 1 # Fetch it now logger.info(' Fetching %s %s', lmsg.pr_repo, lmsg.pr_ref) gitargs = ['fetch', lmsg.pr_repo, lmsg.pr_ref] ecode, out = b4.git_run_command(gitdir, gitargs, logstderr=True) if ecode > 0: logger.critical('ERROR: Could not fetch remote:') logger.critical(out) return ecode config = b4.get_main_config() if check_sig and config['attestation-policy'] != 'off': attest_fetch_head(gitdir, lmsg) logger.info('---') if branch: gitargs = ['checkout', '-b', branch, 'FETCH_HEAD'] logger.info('Fetched into branch %s', branch) ecode, out = b4.git_run_command(gitdir, gitargs) if ecode > 0: logger.critical('ERROR: Failed to create branch') logger.critical(out) return ecode else: logger.info('Successfully fetched into FETCH_HEAD') if ty_track: thanks_record_pr(lmsg) return 0 def thanks_record_pr(lmsg: b4.LoreMessage) -> None: datadir = b4.get_data_dir() # Check if we're tracking it already filename = '%s.pr' % lmsg.pr_remote_tip_commit for entry in os.listdir(datadir): if entry == filename: return allto = utils.getaddresses([str(x) for x in lmsg.msg.get_all('to', [])]) allcc = utils.getaddresses([str(x) for x in lmsg.msg.get_all('cc', [])]) out = { 'msgid': lmsg.msgid, 'subject': lmsg.full_subject, 'fromname': lmsg.fromname, 'fromemail': lmsg.fromemail, 'to': b4.format_addrs(allto, clean=False), 'cc': b4.format_addrs(allcc, clean=False), 'references': b4.LoreMessage.clean_header(lmsg.msg['References']), 'remote': lmsg.pr_repo, 'ref': lmsg.pr_ref, 'sentdate': b4.LoreMessage.clean_header(lmsg.msg['Date']), 'quote': b4.make_quote(lmsg.body, maxlines=6) } fullpath = os.path.join(datadir, filename) with open(fullpath, 'w', encoding='utf-8') as fh: 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: Optional[str], lmsg: b4.LoreMessage, mailfrom: Optional[str] = None) -> List[email.message.Message]: import b4.ez ecode = fetch_remote(gitdir, lmsg, check_sig=False, ty_track=False) if ecode > 0: raise RuntimeError('Fetching unsuccessful') if not lmsg.pr_base_commit: # Use git merge-base between HEAD and FETCH_HEAD to find # where we should start logger.info('Running git merge-base to find common ancestry') gitargs = ['merge-base', 'HEAD', 'FETCH_HEAD'] ecode, out = b4.git_run_command(gitdir, gitargs, logstderr=True) if ecode > 0: logger.critical('Could not find common ancestry.') logger.critical(out) raise RuntimeError('Could not find common ancestry') lmsg.pr_base_commit = out.strip() if lmsg.pr_base_commit == lmsg.pr_tip_commit: logger.critical('Cannot auto-discover merge-base on a merged pull request.') raise RuntimeError('Cannot find merge-base on a merged pull request') logger.info('Generating patches starting from the base-commit') prefixes = list() for prefix in lmsg.lsubject.prefixes: if prefix.lower() not in ('git', 'pull'): prefixes.append(prefix) # get our To's and CC's allto = utils.getaddresses(lmsg.msg.get_all('to', [])) allcc = utils.getaddresses(lmsg.msg.get_all('cc', [])) emlfrom = email.utils.parseaddr(b4.LoreMessage.clean_header(lmsg.msg.get('From'))) if mailfrom is None: mailfrom = emlfrom else: mailfrom = email.utils.parseaddr(mailfrom) vianame = mailfrom[0] if not vianame: vianame = 'B4 Explode' if emlfrom[1].lower() != mailfrom[1].lower(): mailfrom = (f'{emlfrom[0]} via {vianame}', mailfrom[1]) config = b4.get_main_config() msgid_tpt = f'' pmsgs = b4.git_range_to_patches(gitdir, lmsg.pr_base_commit, 'FETCH_HEAD', prefixes=prefixes, msgid_tpt=msgid_tpt, seriests=int(lmsg.date.timestamp()), mailfrom=mailfrom) msgs = list() # Build the cover message from the pull request body cbody = '%s\n\nbase-commit: %s\npull-request: %s\n' % ( lmsg.body.strip(), lmsg.pr_base_commit, config['linkmask'] % lmsg.msgid) if len(pmsgs) == 1: b4.ez.mixin_cover(cbody, pmsgs) else: lmsg.lsubject.prefixes = prefixes b4.ez.add_cover(lmsg.lsubject, msgid_tpt, pmsgs, cbody, int(lmsg.date.timestamp())) for at, (commit, msg) in enumerate(pmsgs): msg.add_header('To', b4.format_addrs(allto)) if allcc: msg.add_header('Cc', b4.format_addrs(allcc)) if lmsg.msg['List-Id']: msg.add_header('X-Original-List-Id', b4.LoreMessage.clean_header(lmsg.msg['List-Id'])) msgs.append(msg) logger.info(' %s', re.sub(r'\n\s*', ' ', msg.get('Subject'))) logger.info('Exploded %s messages', len(msgs)) return msgs def get_pr_from_github(ghurl: str) -> Optional[b4.LoreMessage]: loc = urllib.parse.urlparse(ghurl) chunks = loc.path.strip('/').split('/') rproj = chunks[0] rrepo = chunks[1] rpull = chunks[-1] apiurl = f'https://api.github.com/repos/{rproj}/{rrepo}/pulls/{rpull}' req = requests.session() # Do we have a GitHub API key? config = b4.get_main_config() ghkey = config.get('gh-api-key') if ghkey: req.headers.update({'Authorization': f'token {ghkey}'}) req.headers.update({'Accept': 'application/vnd.github.v3+json'}) resp = req.get(apiurl) if resp.status_code != 200: logger.critical('Server returned an error: %s', resp.status_code) return None prdata = resp.json() head = prdata.get('head', {}) repo = head.get('repo', {}) base = prdata.get('base', {}) user = prdata.get('user', {}) ulogin = user.get('login') fake_email = f'{ulogin}@github.com' apiurl = f'https://api.github.com/users/{ulogin}' resp = req.get(apiurl) if resp.status_code == 200: udata = resp.json() uname = udata.get('name') if not uname: uname = ulogin uemail = udata.get('email') if not uemail: uemail = fake_email else: uname = ulogin uemail = fake_email msg = email.message.EmailMessage(policy=b4.emlpolicy) msg['From'] = f'{uname} <{uemail}>' title = prdata.get('title', '') msg['Subject'] = f'[GIT PULL] {title}' msg['Message-Id'] = utils.make_msgid(idstring=f'{rproj}-{rrepo}-pr-{rpull}', domain='github.com') created_at = utils.format_datetime(datetime.strptime(prdata.get('created_at'), '%Y-%m-%dT%H:%M:%SZ')) msg['Date'] = created_at msg.set_charset('utf-8') body = prdata.get('body') if not body: body = '' msg.set_payload(body, charset='utf-8') lmsg = b4.LoreMessage(msg) lmsg.pr_base_commit = base.get('sha') lmsg.pr_repo = repo.get('clone_url') lmsg.pr_ref = head.get('ref') lmsg.pr_tip_commit = head.get('sha') lmsg.pr_remote_tip_commit = head.get('sha') return lmsg def main(cmdargs: argparse.Namespace) -> None: gitdir = cmdargs.gitdir lmsg = None if not cmdargs.no_stdin and not sys.stdin.isatty(): logger.debug('Getting PR message from stdin') msg = email.message_from_bytes(sys.stdin.buffer.read()) cmdargs.msgid = b4.LoreMessage.get_clean_msgid(msg) lmsg = parse_pr_data(msg) else: if cmdargs.msgid and 'github.com' in cmdargs.msgid and '/pull/' in cmdargs.msgid: logger.debug('Getting PR info from Github') lmsg = get_pr_from_github(cmdargs.msgid) else: logger.debug('Getting PR message from public-inbox') msgid = b4.get_msgid(cmdargs) msgs = b4.get_pi_thread_by_msgid(msgid) if not msgs: return for msg in msgs: mmsgid = b4.LoreMessage.get_clean_msgid(msg) if mmsgid == msgid: lmsg = parse_pr_data(msg) break if lmsg is None or lmsg.pr_remote_tip_commit is None: logger.critical('ERROR: Could not find pull request info in %s', cmdargs.msgid) sys.exit(1) if not lmsg.pr_tip_commit: lmsg.pr_tip_commit = lmsg.pr_remote_tip_commit if cmdargs.explode: # Set up a temporary clone with b4.git_temp_clone(gitdir) as tc: try: msgs = explode(tc, lmsg, mailfrom=cmdargs.mailfrom) except RuntimeError: logger.critical('Nothing exploded.') sys.exit(1) if msgs: if cmdargs.sendidentity: # Pass exploded series via git-send-email config = b4.get_config_from_git(rf'sendemail\.{cmdargs.sendidentity}\..*') if not len(config): logger.critical('Not able to find sendemail.%s configuration', cmdargs.sendidentity) sys.exit(1) # Make sure from is not overridden by current user mailfrom = msgs[0].get('from') gitargs = ['send-email', '--identity', cmdargs.sendidentity, '--from', mailfrom] if cmdargs.dryrun: gitargs.append('--dry-run') # Write out everything into a temporary dir counter = 0 with tempfile.TemporaryDirectory() as tfd: for msg in msgs: outfile = os.path.join(tfd, '%04d' % counter) with open(outfile, 'wb') as tfh: tfh.write(msg.as_bytes(policy=b4.emlpolicy)) gitargs.append(outfile) counter += 1 ecode, out = b4.git_run_command(cmdargs.gitdir, gitargs, logstderr=True) if cmdargs.dryrun: logger.info(out) sys.exit(ecode) config = b4.get_main_config() if config.get('save-maildirs', 'no') == 'yes': save_maildir = True dftext = 'maildir' else: save_maildir = False dftext = 'mbx' savefile = cmdargs.outmbox if savefile is None: savefile = f'{lmsg.msgid}.{dftext}' if os.path.exists(savefile): logger.info('File exists: %s', savefile) sys.exit(1) if save_maildir: b4.save_maildir(msgs, savefile) else: with open(savefile, 'wb') as fh: b4.save_git_am_mbox(msgs, fh) logger.info('---') logger.info('Saved %s', savefile) sys.exit(0) else: logger.critical('Nothing exploded.') sys.exit(1) exists = b4.git_commit_exists(gitdir, lmsg.pr_tip_commit) if exists: # Is it in any branch, or just flapping in the wind? branches = b4.git_branch_contains(gitdir, lmsg.pr_tip_commit) if len(branches): logger.info('Pull request tip commit exists in the following branches:') for branch in branches: logger.info(' %s', branch) if cmdargs.check: sys.exit(0) sys.exit(1) # Is it at the tip of FETCH_HEAD? loglines = b4.git_get_command_lines(gitdir, ['log', '-1', '--pretty=oneline', 'FETCH_HEAD']) if len(loglines) and loglines[0].find(lmsg.pr_tip_commit) == 0: logger.info('Pull request is at the tip of FETCH_HEAD') if cmdargs.check: attest_fetch_head(gitdir, lmsg) sys.exit(0) elif cmdargs.check: logger.info('Pull request does not appear to be in this tree.') sys.exit(0) fetch_remote(gitdir, lmsg, branch=cmdargs.branch) b4-0.13.0/b4/ty.py000066400000000000000000000572331456345453500134430ustar00rootroot00000000000000#!/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 os import sys import b4 import re import email import email.message import email.policy import json import argparse from string import Template from email import utils from pathlib import Path from typing import Optional, Tuple, Union, List, Dict logger = b4.logger DEFAULT_PR_TEMPLATE = """ On ${sentdate}, ${fromname} wrote: ${quote} Merged, thanks! ${summary} Best regards, -- ${signature} """ DEFAULT_AM_TEMPLATE = """ On ${sentdate}, ${fromname} wrote: ${quote} Applied, thanks! ${summary} Best regards, -- ${signature} """ # Used to track commits created by current user MY_COMMITS = None # Used to track additional branch info BRANCH_INFO = None def git_get_merge_id(gitdir: Optional[str], commit_id: str, branch: Optional[str] = None) -> Optional[str]: # get merge commit id args = ['rev-list', '%s..' % commit_id, '--ancestry-path'] if branch is not None: args += [branch] lines = b4.git_get_command_lines(gitdir, args) if not len(lines): return None return lines[-1] def git_get_rev_diff(gitdir: Optional[str], rev: str) -> Tuple[int, Union[str, bytes]]: args = ['diff', '%s~..%s' % (rev, rev)] return b4.git_run_command(gitdir, args) def git_get_commit_message(gitdir: Optional[str], rev: str) -> Tuple[int, Union[str, bytes]]: args = ['log', '--format=%B', '-1', rev] return b4.git_run_command(gitdir, args) def make_reply(reply_template: str, jsondata: dict, gitdir: Optional[str]) -> email.message.EmailMessage: msg = email.message.EmailMessage() msg['From'] = '%s <%s>' % (jsondata['myname'], jsondata['myemail']) 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.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']) else: msg['References'] = '<%s>' % jsondata['msgid'] subject = re.sub(r'^Re:\s+', '', jsondata['subject'], flags=re.I) if jsondata.get('cherrypick'): msg.add_header('Subject', 'Re: (subset) ' + subject) else: msg.add_header('Subject', 'Re: ' + subject) mydomain = jsondata['myemail'].split('@')[1] msg['Message-Id'] = email.utils.make_msgid(idstring='b4-ty', domain=mydomain) msg['Date'] = email.utils.formatdate(localtime=True) body = Template(reply_template).safe_substitute(jsondata) msg.set_payload(body, charset='utf-8') msg.set_charset('utf-8') return msg def auto_locate_pr(gitdir: Optional[str], jsondata: dict, branch: str) -> Optional[str]: pr_commit_id = jsondata['pr_commit_id'] logger.debug('Checking %s', jsondata['pr_commit_id']) if not b4.git_commit_exists(gitdir, pr_commit_id): return None onbranches = b4.git_branch_contains(gitdir, pr_commit_id) if not len(onbranches): logger.debug('%s is not on any branches', pr_commit_id) return None if branch not in onbranches: logger.debug('%s is not on branch %s', pr_commit_id, branch) return None # Get the merge commit merge_commit_id = git_get_merge_id(gitdir, pr_commit_id, branch) if not merge_commit_id: logger.debug('Could not get a merge commit-id for %s', pr_commit_id) return None # Check that we are the author of the merge commit gitargs = ['show', '--format=%ae', merge_commit_id] out = b4.git_get_command_lines(gitdir, gitargs) if not out: logger.debug('Could not get merge commit author for %s', pr_commit_id) return None usercfg = b4.get_user_config() if usercfg['email'] not in out: logger.debug('Merged by a different author, ignoring %s', pr_commit_id) logger.debug('Author: %s', out[0]) return None return merge_commit_id def get_all_commits(gitdir: Optional[str], branch: str, since: str = '1.week', committer: Optional[str] = None) -> Dict[str, Tuple[str, str, List[str]]]: global MY_COMMITS if MY_COMMITS is not None: return MY_COMMITS MY_COMMITS = dict() if committer is None: usercfg = b4.get_user_config() committer = usercfg['email'] gitargs = ['log', '--committer', committer, '--no-abbrev', '--no-decorate', '--oneline', '--since', since, branch] lines = b4.git_get_command_lines(gitdir, gitargs) if not len(lines): logger.debug('No new commits from the current user --since=%s', since) return MY_COMMITS logger.info('Found %s of your commits since %s', len(lines), since) logger.info('Calculating patch hashes, may take a moment...') # Get patch hash of each commit for line in lines: commit_id, subject = line.split(maxsplit=1) ecode, out = git_get_rev_diff(gitdir, commit_id) pwhash = b4.LoreMessage.get_patchwork_hash(out) logger.debug('phash=%s', pwhash) # get all message-id or link trailers ecode, out = git_get_commit_message(gitdir, commit_id) matches = re.findall(r'^\s*(?:message-id|link):[ \t]+(\S+)\s*$', out, flags=re.I | re.M) trackers = list() if matches: for tvalue in matches: trackers.append(tvalue) MY_COMMITS[pwhash] = (commit_id, subject, trackers) return MY_COMMITS def auto_locate_series(gitdir: Optional[str], jsondata: dict, branch: str, since: str = '1.week') -> List[Tuple[int, Optional[str]]]: commits = get_all_commits(gitdir, branch, since) patchids = set(commits.keys()) # We need to find all of them in the commits found = list() matches = 0 at = 0 for patch in jsondata['patches']: at += 1 logger.debug('Checking %s', patch) if patch[1] in patchids: logger.debug('Found: %s', patch[0]) found.append((at, commits[patch[1]][0])) matches += 1 else: # try to locate by subject success = False for pwhash, commit in commits.items(): if commit[1] == patch[0]: logger.debug('Matched using subject') found.append((at, commit[0])) success = True matches += 1 break elif len(patch) > 2 and len(patch[2]) and len(commit[2]): for tracker in commit[2]: if tracker.find(patch[2]) >= 0: logger.debug('Matched using recorded message-id') found.append((at, commit[0])) success = True matches += 1 break if success: break if not success: logger.debug(' Failed to find a match for: %s', patch[0]) found.append((at, None)) return found def set_branch_details(gitdir: Optional[str], branch: str, jsondata: dict, config: dict) -> Tuple[dict, dict]: binfo = get_branch_info(gitdir, branch) jsondata['branch'] = branch for key, val in binfo.items(): if key == 'b4-treename': config['thanks-treename'] = val elif key == 'b4-commit-url-mask': config['thanks-commit-url-mask'] = val elif key == 'b4-pr-template': config['thanks-pr-template'] = val elif key == 'b4-am-template': config['thanks-am-template'] = val elif key == 'branch': jsondata['branch'] = val if 'thanks-treename' in config: jsondata['treename'] = config['thanks-treename'] elif 'url' in binfo: # noinspection PyBroadException try: # Try to grab the last two chunks of the path purl = Path(binfo['url']) jsondata['treename'] = os.path.join(purl.parts[-2], purl.parts[-1]) except: # Something went wrong... just use the whole URL jsondata['treename'] = binfo['url'] else: jsondata['treename'] = 'local tree' return jsondata, config def generate_pr_thanks(gitdir: Optional[str], jsondata: dict, branch: str) -> email.message.EmailMessage: config = b4.get_main_config() jsondata, config = set_branch_details(gitdir, branch, jsondata, config) thanks_template = DEFAULT_PR_TEMPLATE if config['thanks-pr-template']: # Try to load this template instead try: 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']) sys.exit(2) if 'merge_commit_id' not in jsondata: merge_commit_id = git_get_merge_id(gitdir, jsondata['pr_commit_id']) if not merge_commit_id: logger.critical('Could not get merge commit id for %s', jsondata['subject']) logger.critical('Was it actually merged?') sys.exit(1) jsondata['merge_commit_id'] = merge_commit_id # Make a summary cidmask = config['thanks-commit-url-mask'] if not cidmask: cidmask = 'merge commit: %s' jsondata['summary'] = cidmask % jsondata['merge_commit_id'] msg = make_reply(thanks_template, jsondata, gitdir) return msg def generate_am_thanks(gitdir: Optional[str], jsondata: dict, branch: str, since: str) -> email.message.EmailMessage: config = b4.get_main_config() jsondata, config = set_branch_details(gitdir, branch, jsondata, config) thanks_template = DEFAULT_AM_TEMPLATE if config['thanks-am-template']: # Try to load this template instead try: 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']) sys.exit(2) if 'commits' not in jsondata: commits = auto_locate_series(gitdir, jsondata, branch, since) else: commits = jsondata['commits'] cidmask = config['thanks-commit-url-mask'] if not cidmask: cidmask = 'commit: %s' slines = list() nomatch = 0 padlen = len(str(len(commits))) patches = jsondata['patches'] for at, cid in commits: try: prefix = '[%s] ' % patches[at - 1][3] except IndexError: prefix = '[%s/%s] ' % (str(at).zfill(padlen), len(commits)) slines.append('%s%s' % (prefix, str(patches[at - 1][0]))) if cid is None: slines.append('%s(no commit info)' % (' ' * len(prefix))) nomatch += 1 else: slines.append('%s%s' % (' ' * len(prefix), cidmask % cid)) jsondata['summary'] = '\n'.join(slines) if nomatch == len(commits): logger.critical(' WARNING: None of the patches matched for: %s', jsondata['subject']) logger.critical(' Please review the resulting message') elif nomatch > 0: logger.critical(' WARNING: Could not match %s of %s patches in: %s', nomatch, len(commits), jsondata['subject']) logger.critical(' Please review the resulting message') msg = make_reply(thanks_template, jsondata, gitdir) return msg def auto_thankanator(cmdargs: argparse.Namespace) -> None: gitdir = cmdargs.gitdir wantbranch = get_wanted_branch(cmdargs) logger.info('Auto-thankanating commits in %s', wantbranch) tracked = list_tracked() if not len(tracked): logger.info('Nothing to do') sys.exit(0) applied = list() for jsondata in tracked: if 'pr_commit_id' in jsondata: # this is a pull request merge_commit_id = auto_locate_pr(gitdir, jsondata, wantbranch) if merge_commit_id is None: continue jsondata['merge_commit_id'] = merge_commit_id else: # This is a patch series commits = auto_locate_series(gitdir, jsondata, wantbranch, since=cmdargs.since) # Weed out series that have no matches at all found = False for commit in commits: if commit[1] is not None: found = True break if not found: continue jsondata['commits'] = commits applied.append(jsondata) logger.info(' Located: %s', jsondata['subject']) if not len(applied): logger.info('Nothing to do') sys.exit(0) logger.info('---') send_messages(applied, wantbranch, cmdargs) sys.exit(0) def send_messages(listing: List[Dict], branch: str, cmdargs: argparse.Namespace) -> None: logger.info('Generating %s thank-you letters', len(listing)) gitdir = cmdargs.gitdir datadir = b4.get_data_dir() fromaddr = None smtp = None config = b4.get_main_config() if cmdargs.sendemail or config.get('ty-send-email', 'no').lower() in ['yes', 'true', '1']: send_email = True try: smtp, fromaddr = b4.get_smtp() except Exception as ex: # noqa logger.critical('Failed to configure the smtp connection:') logger.critical(ex) sys.exit(1) else: # We write .thanks notes send_email = False # 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() config = b4.get_main_config() user_name = config.get('thanks-from-name', usercfg['name']) user_email = config.get('thanks-from-email', usercfg['email']) signature = b4.get_email_signature() outgoing = 0 msgids = list() for jsondata in listing: jsondata['myname'] = user_name jsondata['myemail'] = user_email jsondata['signature'] = signature if 'pr_commit_id' in jsondata: # This is a pull request msg = generate_pr_thanks(gitdir, jsondata, branch) else: # This is a patch series 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 if send_email: if not fromaddr: fromaddr = jsondata['myemail'] logger.info(' Sending: %s', b4.LoreMessage.clean_header(msg.get('subject'))) b4.send_mail(smtp, [msg], fromaddr, dryrun=cmdargs.dryrun) 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, 'wb') as fh: fh.write(msg.as_bytes(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 pwstate = cmdargs.pw_set_state if not pwstate: pwstate = config.get('pw-accept-state') if send_email: 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) 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() -> List[Dict]: # find all tracked bits tracked = list() datadir = b4.get_data_dir() paths = sorted(Path(datadir).iterdir(), key=os.path.getmtime) for fullpath in paths: if fullpath.suffix not in ('.pr', '.am'): continue with fullpath.open('r', encoding='utf-8') as fh: jsondata = json.load(fh) jsondata['trackfile'] = fullpath.name if fullpath.suffix == '.pr': jsondata['pr_commit_id'] = fullpath.stem tracked.append(jsondata) return tracked def write_tracked(tracked: List[Dict]) -> None: counter = 1 config = b4.get_main_config() logger.info('Currently tracking:') for entry in tracked: logger.info('%3d: %s', counter, entry['subject']) logger.info(' From: %s <%s>', entry['fromname'], entry['fromemail']) logger.info(' Date: %s', entry['sentdate']) logger.info(' Link: %s', config['linkmask'] % entry['msgid']) counter += 1 def thank_selected(cmdargs: argparse.Namespace) -> None: tracked = list_tracked() if not len(tracked): logger.info('Nothing to do') sys.exit(0) if cmdargs.thankfor == 'all': listing = tracked else: listing = list() for num in b4.parse_int_range(cmdargs.thankfor, upper=len(tracked)): try: index = int(num) - 1 listing.append(tracked[index]) except ValueError: logger.critical('Please provide the number of the message') logger.info('---') write_tracked(tracked) sys.exit(1) except IndexError: logger.critical('Invalid index: %s', num) logger.info('---') write_tracked(tracked) sys.exit(1) if not len(listing): logger.info('Nothing to do') sys.exit(0) wantbranch = get_wanted_branch(cmdargs) send_messages(listing, wantbranch, cmdargs) sys.exit(0) def discard_selected(cmdargs: argparse.Namespace) -> None: tracked = list_tracked() if not len(tracked): logger.info('Nothing to do') sys.exit(0) if cmdargs.discard == 'all': listing = tracked else: listing = list() for num in b4.parse_int_range(cmdargs.discard, upper=len(tracked)): try: index = int(num) - 1 listing.append(tracked[index]) except ValueError: logger.critical('Please provide the number of the message') logger.info('---') write_tracked(tracked) sys.exit(1) except IndexError: logger.critical('Invalid index: %s', num) logger.info('---') write_tracked(tracked) sys.exit(1) if not len(listing): logger.info('Nothing to do') sys.exit(0) 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) def check_stale_thanks(outdir: str) -> None: if os.path.exists(outdir): for entry in Path(outdir).iterdir(): if entry.suffix == '.thanks': logger.critical('ERROR: Found existing .thanks files in: %s', outdir) logger.critical(' Please send them first (or delete if already sent).') logger.critical(' Refusing to run to avoid potential confusion.') sys.exit(1) def get_wanted_branch(cmdargs: argparse.Namespace) -> str: global BRANCH_INFO gitdir = cmdargs.gitdir if not cmdargs.branch: # Find out our current branch gitargs = ['symbolic-ref', '-q', 'HEAD'] ecode, out = b4.git_run_command(gitdir, gitargs) if ecode > 0: logger.critical('Not able to get current branch (git symbolic-ref HEAD)') sys.exit(1) wantbranch = re.sub(r'^refs/heads/', '', out.strip()) logger.debug('will check branch=%s', wantbranch) else: # Make sure it's a real branch gitargs = ['branch', '--format=%(refname)', '--list', '--all', cmdargs.branch] lines = b4.git_get_command_lines(gitdir, gitargs) if not len(lines): logger.critical('Requested branch not found in git branch --list --all %s', cmdargs.branch) sys.exit(1) wantbranch = cmdargs.branch return wantbranch def get_branch_info(gitdir: Optional[str], branch: str) -> Dict: global BRANCH_INFO if BRANCH_INFO is not None: return BRANCH_INFO BRANCH_INFO = dict() remotecfg = b4.get_config_from_git('branch\\.%s\\..*' % branch) if remotecfg is None or 'remote' not in remotecfg: # Did not find a matching branch entry, so look at remotes gitargs = ['remote', 'show'] lines = b4.git_get_command_lines(gitdir, gitargs) if not len(lines): # No remotes? Hmm... return BRANCH_INFO remote = None for entry in lines: if branch.find(f'{entry}/') == 0: remote = entry break if remote is None: # Not found any matching remotes return BRANCH_INFO BRANCH_INFO['remote'] = remote BRANCH_INFO['branch'] = branch.replace(f'{remote}/', '') else: BRANCH_INFO['remote'] = remotecfg['remote'] if 'merge' in remotecfg: BRANCH_INFO['branch'] = re.sub(r'^refs/heads/', '', remotecfg['merge']) # Grab template overrides remotecfg = b4.get_config_from_git('remote\\.%s\\..*' % BRANCH_INFO['remote']) BRANCH_INFO.update(remotecfg) return BRANCH_INFO def main(cmdargs: argparse.Namespace) -> None: usercfg = b4.get_user_config() if 'email' not in usercfg: logger.critical('Please set user.email in gitconfig to use this feature.') sys.exit(1) if cmdargs.auto: check_stale_thanks(cmdargs.outdir) auto_thankanator(cmdargs) elif cmdargs.thankfor: check_stale_thanks(cmdargs.outdir) thank_selected(cmdargs) elif cmdargs.discard: discard_selected(cmdargs) else: tracked = list_tracked() if not len(tracked): logger.info('No thanks necessary.') sys.exit(0) write_tracked(tracked) logger.info('---') logger.info('You can send them using number ranges, e.g:') logger.info(' b4 ty -t 1-3,5,7-') b4-0.13.0/docs/000077500000000000000000000000001456345453500130465ustar00rootroot00000000000000b4-0.13.0/docs/conf.py000066400000000000000000000004001456345453500143370ustar00rootroot00000000000000project = 'B4 end-user docs' copyright = '2019-2022, The Linux Foundation and contributors' author = 'Kernel.org' templates_path = ['_templates'] html_theme = 'sphinx_rtd_theme' # Don't highlight by default highlight_language = 'none' master_doc = 'index' b4-0.13.0/docs/config.rst000066400000000000000000000320671456345453500150550ustar00rootroot00000000000000Configuration options ===================== B4 doesn't have a separate configuration file but will use ``git-config`` to retrieve a set of b4-specific settings. This means that you can have three levels of b4 configuration: - system-wide, in ``/etc/gitconfig`` - per-user, in ``$HOME/.gitconfig`` - per-repo, in ``somerepo/.git/config`` Since the purpose of b4 is to work with git repositories, this allows the usual fall-through configuration that can be overridden by more local settings on the repository level. Additionally, you can set and override configuration options on the command-line using the ``--config`` (or ``-c``) option, for example:: b4 --config b4.midmask=https://some.host/%s Per-project defaults ~~~~~~~~~~~~~~~~~~~~ .. note:: This feature is new in v0.10. A project may ship their own b4 config with some defaults, placed in the toplevel of the git tree. If you're not sure where a configuration option is coming from, check if there is a ``.b4-config`` file in the repository you're currently using. Configuration options --------------------- All settings are under the ``b4`` section. E.g to set a ``b4.midmask`` option, you can just edit your ``~/.gitconfig`` or ``.git/config`` file and add the following section:: [b4] midmask = https://some.host/%s Core options ~~~~~~~~~~~~ These options control many of the core features of b4. ``b4.midmask`` When retrieving threads by message-id, b4 will use ``midmask`` to figure out from which server they should be retrieved. Default: ``https://lore.kernel.org/%s`` ``b4.linkmask`` When automatically generating ``Link:`` trailers, b4 will use this setting to derive the destination URL. If you want a shorter option, you can also use ``https://msgid.link/%s``, which is an alias for lore.kernel.org. Default: ``https://lore.kernel.org/%s`` ``b4.searchmask`` (v0.9+) If the public-inbox server provides a global searchable index (usually in ``/all/``, this setting can be used to query and retrieve matching discussion threads based on specific search terms -- for example, to retrieve trailer updates using a series ``change-id`` identifier. Default: ``https://lore.kernel.org/all/?x=m&t=1&q=%s`` ``b4.linktrailermask`` (v0.13+) This allows overriding the format of the Link: trailer, in case you want to call it something other thank "Link". For example, some projects require "Message-Id" trailers, so you can make b4 behave the way you like by setting:: linktrailermask = Message-Id: <%s> The ``%s`` will be replaced by the message-id. Default: ``Link: https://lore.kernel.org/%s`` ``b4.listid-preference`` (v0.8+) Messages are frequently sent to multiple distribution lists, and some servers may apply content munging to modify the headers or the message content. B4 will deduplicate the results and this configuration option defines the priority given to the ``List-Id`` header. It is a simple comma-separated string with shell-style globbing. Default: ``*.feeds.kernel.org, *.linux.dev,*.kernel.org,*`` ``b4.save-maildirs`` The "mbox" file format is actually several incompatible formats ("mboxo" vs "mboxrd", for example). If you want to avoid dealing with this problem, you can choose to always save retrieved messages as a Maildir instead. Default: ``no`` ``b4.trailer-order`` This lets you control the order of trailers that get added to your own custody section of the commit message. By default, b4 will apply these trailers in the order they were received (because this is mostly consumed by tooling and the order does not matter). However, if you wanted to list things in a specific order, you could try something like:: trailer-order = link*,fixes*,acked*,reviewed*,tested*,* The "chain of custody" is an important concept in patch-based code review process, with each "Signed-off-by" trailer indicating where the custody section of previous reviewer ends and the new one starts. Your own custody section is anything between the previous-to-last "Signed-off-by" trailer (if any) and the bottom of the trailer section. E.g.:: Fixes: abcde (Commit info) Suggested-by: Alex Reporter Signed-off-by: Betty Developer Acked-by: Chandra Acker Reviewed-by: Debby Reviewer Signed-off-by: Ezri Submaintainer Link: https://msgid.link/some@thing.foo Tested-by: Finn Tester Signed-off-by: Your Name Your custody section is beneath "Ezri Submaintainer", so the only trailers considered for reordering are "Link" and "Tested-by" (your own Signed-off-by trailer is always at the bottom of your own custody section). Note: versions prior to v0.10 did not properly respect the chain of custody. Default: ``*`` ``b4.trailers-ignore-from`` (v0.10+) A comma-separated list of addresses that should never be considered for follow-up trailers. This is useful when dealing with reports generated by automated bots that may insert trailer suggestions, such as the "kernel test robot." E.g.:: [b4] trailers-ignore-from = lkp@intel.com, someotherbot@example.org Default: ``None`` ``b4.cache-expire`` B4 will cache retrieved threads by default, and this allows tweaking the time (in minutes) before cache is invalidated. Many commands also allow the ``--no-cache`` flag to force remote lookups. Default: ``10`` .. _shazam_settings: shazam settings ~~~~~~~~~~~~~~~ These settings control how ``b4 shazam`` applies patches to your tree. ``b4.shazam-am-flags`` (v0.9+) Additional flags to pass to ``git am`` when applying patches. Default: ``None`` ``b4.shazam-merge-flags`` (v0.9+) Additional flags to pass to ``git merge`` when performing a merge with ``b4 shazam -M`` Default: ``--signoff`` ``b4.shazam-merge-template`` (v0.9+) Path to a template to use when creating a merge commit. See ``shazam-merge-template.example`` for some info on how to tweak one. Default: ``None`` Attestation settings ~~~~~~~~~~~~~~~~~~~~ ``b4.attestation-policy`` B4 supports domain-level and end-to-end attestation of patches using the `patatt`_ library. There are four different operation modes: * ``off``: do not bother checking attestation at all * ``check``: print green checkmarks when attestation is passing, but nothing if attestation is failing (**DEPRECATED**, use ``softfail``) * ``softfail``: print green checkmarks when attestation is passing and red x-marks when it is failing * ``hardfail``: exit with an error when any attestation checks fail Default: ``softfail`` ``b4.attestation-checkmarks`` When reporting attestation results, b4 can output fancy unicode checkmarks, or plain old ascii ones: * ``fancy``: uses ✓/✗ checkmarks and colours * ``plain``: uses x/v checkmarks and no colours Default: ``fancy`` ``b4.attestation-check-dkim`` Controls whether to perform DKIM attestation checks. Default: ``yes`` ``b4.attestation-staleness-days`` This setting controls how long in the past attestation signatures can be made before we stop considering them valid. This helps avoid an attack where someone resends valid old patches that contain a known vulnerability. Default: ``30`` ``b4.attestation-gnupghome`` This allows setting ``GNUPGHOME`` before running PGP attestation checks using GnuPG. Default: ``None`` ``b4.gpgbin`` If you don't want to use the default ``gpg`` command, you can specify a path to a different binary. B4 will also use git's ``gpg.program`` setting, if found. Default: ``None`` ``b4.keyringsrc`` See ``patatt`` for details on how to configure keyring lookups. For example, you can clone the kernel.org pgpkeys.git repository and use it for attestation without needing to import any keys into your GnuPG keyring:: git clone https://git.kernel.org/pub/scm/docs/kernel/pgpkeys.git Then set the following in your ``~/.gitconfig``:: [b4] keyringsrc = ~/path/to/pgpkeys/.keyring Default: ``None`` .. _ty_settings: Thank-you (ty) settings ~~~~~~~~~~~~~~~~~~~~~~~ These settings control the behaviour of ``b4 ty`` command. ``b4.thanks-pr-template``, ``b4.thanks-am-template`` These settings take a full path to the template to use when generating thank-you messages for contributors. See example templates provided with the project. Default: ``None`` ``b4.thanks-commit-url-mask`` Used when creating summaries for ``b4 ty``, and can be set to a value like:: thanks-commit-url-mask = https://git.kernel.org/username/c/%.12s If not set, b4 will just specify the commit hashes. See this page for more info on convenient git.kernel.org shorterners: https://korg.docs.kernel.org/git-url-shorteners.html Default: ``None`` ``b4.thanks-from-name`` (v0.13+) An custom from name for sending thanks, eg:: thanks-from-name = Project Foo Thanks Bot Default: ``None`` - falls back to user name. ``b4.thanks-from-email`` (v0.13+) An custom from email for sending thanks, eg:: thanks-from-email = thanks-bot@foo.org Default: ``None`` - falls back to user email. ``b4.thanks-treename`` Name of the tree which can be used in thanks templates. Default: ``None`` ``b4.email-exclude`` (v0.9+) A comma-separated list of shell-style globbing patterns with addresses that should always be excluded from the recipient list. Default: ``None`` ``b4.sendemail-identity`` (v0.8+) Sendemail identity to use when sending mail directly from b4 (applies to ``b4 send`` and ``b4 ty``). See ``man git-send-email`` for info about sendemail identities. Default: ``None`` ``b4.ty-send-email`` (v0.11+) When set to ``yes``, will instruct ``b4 ty`` to send email directly instead of generating .thanks files. Default: ``no`` .. _patchwork_settings: Patchwork integration settings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If your project uses a patchwork server, these settings allow you to integrate your b4 workflow with patchwork. ``b4.pw-url`` (v0.10+) The URL of your patchwork server. Note, that this should point at the toplevel of your patchwork installation and NOT at the project patch listing. E.g.: ``https://patchwork.kernel.org/``. Default: ``None`` ``b4.pw-key`` (v0.10+) You should be able to obtain an API key from your patchwork user profile. This API key will be used to perform actions on your behalf. Default: ``None`` ``b4.pw-project`` (v0.10+) This should contain the name of your patchwork project, as seen in the URL subpath to it (e.g. ``linux-usb``). Default: ``None`` ``b4.pw-review-state`` (v0.10+) When patchwork integration is enabled, every time you run ``b4 am`` or ``b4 shazam``, b4 will mark those patches as with this state. E.g.: ``under-review``). Default: ``None`` ``b4.pw-accept-state`` (v0.10+) After you run ``b4 ty`` to thank the contributor, b4 will move the matching patches into this state. E.g.: ``accepted``. Default: ``None`` ``b4.pw-discard-state`` (v0.10+) If you run ``b4 ty -d`` to delete the tracking information for a patch series, it will also be set on the patchwork server with this state. E.g.: ``deferred`` (or ``rejected``). Default: ``None`` .. _contributor_settings: Contributor-oriented settings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``b4.send-endpoint-web`` (v0.10+) The web submission endpoint to use (see :ref:`web_endpoint`). Default: ``None`` ``b4.send-series-to`` (v0.10+) Address or comma-separated addresses to always add to the To: header (see :ref:`prep_recipients`). Default: ``None`` ``b4.send-series-cc`` (v0.10+) Address or comma-separated addresses to always add to the Cc: header (see :ref:`prep_recipients`). Default: ``None`` ``b4.send-no-patatt-sign`` (v0.10+) Do not sign patches with patatt before sending them (unless using the web submission endpoint where signing is required). Default: ``no`` ``b4.send-auto-to-cmd`` (v0.10+) Command to use to generate the list of To: recipients. Has no effect if the specified script is not found in the repository. Default: ``scripts/get_maintainer.pl --nogit --nogit-fallback --nogit-chief-penguins --norolestats --nol`` ``b4.send-auto-cc-cmd`` (v0.10+) Command to use to generate the list of Cc: recipients. Has no effect if the specified script is not found in the repository. Default:: ``scripts/get_maintainer.pl --nogit --nogit-fallback --nogit-chief-penguins --norolestats --nom`` ``b4.send-same-thread`` (v0.13+) When sending a new version of a series, make it part of the same thread as the previous one. The first mail will be sent as a reply to the previous version's cover letter. Default: ``no`` ``b4.prep-cover-strategy`` (v0.10+) Alternative cover letter storage strategy to use (see :ref:`prep_cover_strategies`). Default: ``commit`` ``b4.prep-cover-template`` (v0.10+) Path to the template to use for the cover letter. Default: ``None`` To document ----------- ``b4.gh-api-key`` Deliberately undocumented because the feature is incomplete and poorly tested. .. _`patatt`: https://pypi.org/project/patatt/ b4-0.13.0/docs/contributor/000077500000000000000000000000001456345453500154205ustar00rootroot00000000000000b4-0.13.0/docs/contributor/overview.rst000066400000000000000000000065111456345453500200230ustar00rootroot00000000000000Contributor overview ==================== .. note:: ``b4 prep``, ``b4 send`` and ``b4 trailers`` are available starting with version 0.10. Even though b4 started out as a tool to help maintainers, beginning with the version ``0.10`` there is also a set of features aimed at making it easier for contributors to submit patch series: * ``b4 prep`` allows to get your patch series ready for sending to the maintainer for review * ``b4 send`` simplifies the process of submitting your patches to the upstream maintainer even if you don't have access to a compliant SMTP server * ``b4 trailers`` simplifies the process of retrieving code-review trailers received on the distribution lists and applying them to your tree .. warning:: This is a fairly new set of features and can still be buggy or do something unexpected. While a lot of work has gone into making sure that your git tree is not harmed in any way, it is best to have backups and to always check things with ``--dry-run`` when that option is available. If you come across a bug or unexpected behaviour, please report the problem to the Tools mailing list. Do I still need to be able to send email? ----------------------------------------- While ``b4 send`` makes it possible to submit patches without having access to an SMTP server, you still need a reasonable mail server for participating in conversations and code review. The main benefit of ``b4 send`` is that you no longer have to really care if your mail server performs some kind of content mangling that causes patches to become corrupted, or if it doesn't provide a way to send mail via SMTP. What is the b4 contributor workflow? ------------------------------------ The workflow is very much git-oriented, so you should expect to need to know a lot about such git commands like ``git commit --amend`` and ``git rebase -i``. In general, the process goes like this: 1. Prepare your patch series by using ``b4 prep`` and queueing your commits. Use ``git rebase -i`` to arrange the commits in the right order and to write good commit messages. 2. Prepare your cover letter using ``b4 prep --edit-cover``. You should provide a good overview of what your series does and why you think it will improve the current code. 3. When you are almost ready to send, use ``b4 prep --auto-to-cc`` to collect the relevant addresses from your commits. If your project uses a ``MAINTAINERS`` file, this will also perform the required query to figure out who should be included on your patch series submission. 4. Review the list of addresses that were added to the cover letter and, if you know what you're doing, remove any that you think are unnecessary. 5. Send your series using ``b4 send``. This will automatically reroll your series to the next version and add changelog entries to the cover letter. 6. Await code review and feedback from maintainers. 7. Apply any received code-review trailers using ``b4 trailers -u``. 8. Use ``git rebase -i`` to make any changes to the code based on the feedback you receive. Remember to record these changes in the cover letter's changelog. 9. Unless series is accepted upstream, GOTO 3. 10. Clean up obsolete prep-managed branches using ``b4 prep --cleanup`` Please read the rest of these docs for details on the ``prep``, ``send``, and ``trailers`` subcommands. b4-0.13.0/docs/contributor/prep.rst000066400000000000000000000315661456345453500171330ustar00rootroot00000000000000prep: preparing your patch series ================================= The first stage of contributor workflow is to prepare your patch series for submission upstream. It generally consists of the following stages: 1. start a new topical branch using ``b4 prep -n topical-name`` 2. add commits as usual and work with them using ``git rebase -i`` 3. prepare the cover letter using ``b4 prep --edit-cover`` 4. prepare the list of recipients using ``b4 prep --auto-to-cc`` Starting a new topical branch ----------------------------- When you are ready to start working on a new submission, the first step is to create a topical branch:: b4 prep -n descriptive-name [-f tagname] It is important to give your branch a short descriptive name, because it will become part of the unique ``change-id`` that will be used to track your proposal across revisions. In other words, don't call it "stuff" or "foo". This command will do the following: 1. Create a new branch called ``b4/descriptive-name`` and switch to it. 2. Create an empty commit with a cover letter template. .. note:: Generally, you will want to fork from some known point in the history, not from some random HEAD commit. You can use ``-f`` to specify a fork-point for b4 to use, such as a recent tag name. You can then edit the cover letter using:: b4 prep --edit-cover This will fire up a text editor using your defined ``$EDITOR`` or ``core.editor`` and automatically update the cover letter commit when you are done. .. _prep_cover_strategies: Cover letter strategies ~~~~~~~~~~~~~~~~~~~~~~~ By default, b4 will keep the cover letter in an empty commit at the start of your series. This has the following benefits: * it is easy to keep track where your series starts without needing to keep a "tracking base branch" around * you can view and edit the cover letter using regular git commands (``git log``, ``git rebase -i``) * you can push the entire branch to a remote and pull it from a different location to continue working on your series from a different system However, keeping an empty commit in your history can have some disadvantages in some less-common situations: * it complicates merging between branches * some non-native git tools may drop empty commits * editing the cover letter rewrites the commit history of the entire branch For this reason, b4 supports alternative strategies for storing the cover letter, which can be set using the ``b4.prep-cover-strategy`` configuration variable. ``commit`` strategy (default) This is the default strategy that keeps the cover letter and all tracking information in an empty commit at the start of your series. See above for upsides and downsides. This strategy is recommended for developers who mostly send out patch series and do not handle actual subsystem tree management (merging submissions from sub-maintainers, cherry-picking, etc). ``branch-description`` strategy This keeps the cover letter and all tracking information outside of the git commits by using the branch description configuration value (stored locally in ``.git/config``). Upsides: * this is how git expects you to handle cover letters (see ``git format-patch --cover-from-description``) * editing the cover letter does not rewrite commit history * merging between branches is easiest Downsides: * the cover letter cannot be pushed to a remote and only exists local to your tree * you have to rely on the base branch for keeping track of where your series starts ``tip-commit`` strategy This is similar to the default ``commit`` strategy, but instead of keeping the cover letter and all tracking information in an empty commit at the start of your series, it keeps it at the end ("tip") of your series. Upsides: * allows you to push the series to a remote and pull it from a different location to continue working on a series * editing the cover letter does not rewrite commit history, which may be easier when working in teams Downsides: * adding new commits is a bit more complicated, because you have to immediately rebase them to be in front of the cover letter * you have to rely on the base branch for keeping track of where your series starts .. note:: At this time, you cannot easily switch from one strategy to the other once you have created the branch with ``b4 prep -n``. This may be supported in the future. Enrolling an existing branch ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you've already started working on a set of commits without first running ``b4 prep -n``, you can enroll your existing branch to make it "prep-tracked." For example, if you have a branch called ``my-topical-branch`` that was forked from ``master``, you can enroll it with b4:: b4 prep -e master Once that completes, you should be able to edit the cover letter and use all other b4 contributor-oriented commands. Creating a branch from a sent series ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you have previously sent a patch series, you can create your new topical branch from that submission by passing the ``--from-thread`` parameter to ``b4 prep -n``. All you need is the msgid of the series, e.g.:: b4 prep -n my-topical-branch -F some-msgid@localhost If the series was submitted using ``b4 send`` it will even contain all the preserved tracking information, but it's not a requirement and should work reasonably well with most patch series. Working with commits -------------------- All your commits in a prep-tracked branch are just regular git commits and you can work with them using any regular git tooling: * you can rebase them on a different (or an updated) branch using ``git rebase`` * you can amend (reword, split, squash, etc) commits interactively using ``git rebase -i``; there are many excellent tutorials available online on how to use interactive rebase Unless you are using a very old version of git, your empty cover letter commit should be preserved through all rebase operations. .. note:: You can edit the cover letter using regular git operations, though it is not recommended (best to do it with ``b4 prep --edit-cover``). If you do want to edit it directly using ``git rebase -i``, remember to use ``git commit --allow-empty`` to commit it back into the tree. What if I only have a single patch? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When you only have a single patch, the contents of the cover letter will be mixed into the "under-the-cut" portion of the patch. You can just use the cover letter for extra To/Cc trailers and changelog entries as your patch goes through revisions. If you add more commits in the future version, you can fill in the cover letter content with additional information about the intent of your entire series. .. _prep_recipients: Prepare the list of recipients ------------------------------ When you are getting ready to submit your work, you need to figure out who the recipients of your series should be. By default, b4 will send the series to any address mentioned in the trailers (and to any other addresses you tell it to use). For the Linux kernel, a required step is to gather the recipients from the output of ``get_maintainer.pl``, which b4 will do for you automatically when you run the ``auto-to-cc`` command:: b4 prep --auto-to-cc The recipients will be added to the cover letter as extra ``To:`` and ``Cc:`` trailers. It is normal for this list to be very large if your change is touching a lot of files. You can add or remove recipients by adding or removing the recipient trailers from the cover letter using ``b4 prep --edit-cover``. For projects that are not using the MAINTAINERS file, there is usually a single list where you should send your changes. You can set that in the repository's ``.git/config`` file as follows:: [b4] send-series-to = some@list.name This may also be already set by the project, if they have a ``.b4-config`` file in the root of their git repository. .. _prep_cleanup: Cleaning up old work -------------------- Once your series is accepted upstream, you can archive and clean up the prep-managed branch and all its sent tags:: b4 prep --cleanup This will list all prep-managed branches in your repository. Pick a branch to clean up (make sure it's not currently checked out), and run the command again:: b4 prep --cleanup b4/my-topical-branch After you confirm your action, this will create a tarball with all the patches, cover letters, and tracking information from your series, after which the branch and related tags will be deleted from your local repository. .. _prep_flags: Prep command flags ------------------ Please also see :ref:`contributor_settings`, which allow setting or modifying defaults for some of these flags. ``-c, --auto-to-cc`` Automatically populate the cover letter with addresses collected from commit trailers. If a ``MAINTAINERS`` file is found, together with ``scripts/get_maintainer.pl``, b4 will automatically perform the query to collect the maintainers and lists that should be notified of the change. ``-p OUTPUT_DIR, --format-patch OUTPUT_DIR`` This will output your tracked series as patches similar to what ``git-format-patch`` would do. ``--edit-cover`` Lets you edit the cover letter using whatever editor is defined in git-config for ``core.editor``, ``$EDITOR`` if that is not found, or ``vim`` because we're pretty sure that if you don't like vim, you would have already set your ``$EDITOR`` to not be vim. ``--show-revision`` Shows the current series revision. ``--force-revision N`` Forces the revision to a different integer number. This modifies your cover letter and tracking information and makes this change permanent. ``--compare-to vN`` **(v0.11+)** This executes a ``git range-diff`` command that lets you compare the previously sent version of the series to what is currently in your working branch. This is very useful right before sending off a new revision to make sure that you didn't forget to include anything into changelogs. ``--manual-reroll MSGID`` Normally, your patch series will be automatically rerolled to the next version after a successful ``b4 send`` (see :doc:`send`). However, if you sent it in using some other mechanism, such as ``git-send-email``, you can trigger a manual reroll using this command. It requires a message-id that can be retrieved from the public-inbox server, so we can properly add the reference to the previously sent series to the cover letter changelog. ``--set-prefixes PREFIX [PREFIX ...]`` **(v0.11+)** If you want to mark your patch as ``RFC``, ``WIP``, or add any other subsystem identifiers, you can define them via this command. Do **not** add ``PATCH`` or ``v1`` here, as these will already be automatically added to the subject lines. To remove any extra prefixes you previously set, you can run ``--set-prefixes ''``. Alternatively, you can add any extra prefixes to the cover letter subject line, using the usual square brackets notation, e.g.:: [RFC] Cover letter subject When b4 sends the message, it will be expanded with the usual ``PATCH``, ``vN``, etc. ``--show-info [PARAM]`` **(v0.13+)** Dumps information about the current series that can be parsed by other tools. Starting with v0.13, he parameter can be one of the following: - **keyname** to show just a specific value from the current branch - **branchname** to show all info about a specific branch - **branchname:keyname** to show a specific value from a specific branch For example, if you have a branch called ``b4/foodrv-bar`` and you want to display the ``series-range`` value, run:: b4 prep --show-info b4/foodrv-bar:series-range Or, to show all values for branch ``b4/foodrv-bar``:: b4 prep --show-info b4/foodrv-bar Or, to show ``series-range`` for the current branch:: b4 prep --show-info series-range And, to show all values for the current branch:: b4 prep --show-info ``--cleanup [BRANCHNAME]`` **(v0.13+)** Archive and delete obsolete prep-managed branches and all git objects related to them (such as sent tags). Run without parameters to list all known prep-managed branches in the repository. Rerun with the branch name to create an archival tarball with all patches, covers, and tracking information, and then delete all git objects related to that series from the local repository. ``-n NEW_SERIES_NAME, --new NEW_SERIES_NAME`` Creates a new branch to start work on a new patch series. ``-f FORK_POINT, --fork-point FORK_POINT`` When creating a new branch, use a specific fork-point instead of whatever commit happens to be at the current ``HEAD``. ``-F MSGID, --from-thread MSGID`` After creating a new branch, populate it with patches from this pre-existing patch series. Requires a message-id that can be retrieved from the public-inbox server. ``-e ENROLL_BASE, --enroll ENROLL_BASE`` Enrolls your current branch to be b4-prep managed. Requires the name of the branch to use as the fork-point tracking base. b4-0.13.0/docs/contributor/send.rst000066400000000000000000000252451456345453500171130ustar00rootroot00000000000000send: sending in your work ========================== B4 supports sending your series either via your own SMTP server, or via a web submission endpoint. Upsides of using your own SMTP server: * it is part of decentralized infrastructure not dependent on a single point of failure * it adds domain-level attestation to your messages via DKIM signatures * it avoids the need to munge the From: headers in patches, which is required for email delivery that originates at a different domain However, using your own SMTP server may not always be a valid option: * your mail provider may not offer an SMTP compliant server for sending mail (e.g. if it only uses a webmail/exchange client) * there may be limits on the number of messages you can send through your SMTP server in a short period of time (which is normal for large patch series) * your company SMTP server may modify the message bodies by adding huge legal disclaimers to all outgoing mail The web submission endpoint helps with such cases, plus offers several other upsides: * the messages are written to a public-inbox feed, which is then immediately available for others to follow and query * all patches are end-to-end attested with the developer signature * messages are less likely to get lost or delayed .. note:: Even if you opt to use the web submission endpoint, you still need a valid email account for participating in decentralized development -- you will need it to take part in discussions and for sending and receiving code review feedback. .. _web_endpoint: Authenticating with the web submission endpoint ----------------------------------------------- Before you start, you will need to configure your attestation mechanism. If you already have a PGP key configured for use with git, you can just use that and skip the next section. If you don't already have a PGP key, you can create a separate ed25519 key just for web submission purposes. Creating a new ed25519 key ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. note:: Creating a new ed25519 key is not required if you already have a PGP key configured with git using the ``user.signingKey`` git-config setting. Installing b4 should have already pulled in the patatt patch attestation library. You can use the command line tool to create your ed25519 key:: $ patatt genkey Generating a new ed25519 keypair Wrote: /home/user/.local/share/patatt/private/20220915.key Wrote: /home/user/.local/share/patatt/public/20220915.pub Wrote: /home/user/.local/share/patatt/public/ed25519/example.org/alice.developer/20220915 Add the following to your .git/config (or global ~/.gitconfig): --- [patatt] signingkey = ed25519:20220915 selector = 20220915 --- Next, communicate the contents of the following file to the repository keyring maintainers for inclusion into the project: /home/user/.local/share/patatt/public/20220915.pub Copy the ``[patatt]`` section and add it to your ``~/.gitconfig`` or to your ``.git/config`` in the repository that you want to enable for ``b4 send``. Configuring the web endpoint ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The web endpoint you will use is going to be dependent on the project. For the Linux kernel and associated tools (like Git, B4, patatt, etc), the kernel.org endpoint can be enabled by adding the following to your ``~/.gitconfig``:: [b4] send-endpoint-web = https://lkml.kernel.org/_b4_submit .. note:: The kernel.org endpoint can only be used for kernel.org-hosted projects. If there are no recognized mailing lists in the to/cc headers, then the submission will be rejected. Once that is added, you can request authentication, as in the example below:: $ b4 send --web-auth-new Will submit a new email authorization request to: Endpoint: https://lkml.kernel.org/_b4_submit Name: Alice Developer Identity: alice.developer@example.org Selector: 20220915 Pubkey: ed25519:ABCDE1lNXHvHOTuHV+Cf1eK9SuRNZZYrQmcJ44IkE8Q= --- Press Enter to confirm or Ctrl-C to abort Submitting new auth request to https://lkml.kernel.org/_b4_submit --- Challenge generated and sent to alice.developer@example.org Once you receive it, run b4 send --web-auth-verify [challenge-string] As the instructions say, you should receive a verification email to the address you specified in your ``user.email``. Once you have received it, run the verification command by copy-pasting the UUID from the confirmation message:: $ b4 send --web-auth-verify abcd9b34-2ecf-4d25-946a-0631c414227e Signing challenge Submitting verification to https://lkml.kernel.org/_b4_submit --- Challenge successfully verified for alice.developer@example.org You may now use this endpoint for submitting patches. You should now be able to send patches via this web submission endpoint. Using your own SMTP server -------------------------- If there is a ``sendmail`` section in your git configuration, B4 will try use that by default instead of the web endpoint. Only the most common subset of options are supported. The vast majority of servers will only need the following settings:: [sendemail] smtpServer = smtp.example.org smtpServerPort = 465 smtpEncryption = ssl smtpUser = alice.developer@example.org smtpPass = [omitted] You can also set up msmtp or a similar tool and specify the path to the ``sendmail``-compliant binary as the value for ``smtpServer``. You can force B4 to use the web endpoint by using the ``--use-web-endpoint`` argument. Sending your patches -------------------- Once your web endpoint or SMTP server are configured, you can start sending your work. .. note:: At this time, only series prepared with ``b4 prep`` are supported, but future versions may support sending arbitrary patches generated with ``git format-patch``. Checking things over with ``-o`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ It is a good idea to first check that everything is looking good by running the send command with ``-o somedir``, e.g.:: b4 send -o /tmp/presend This will write out the messages just as they would be sent out, giving you a way to check that everything is looking as it should. Checking things over with ``--reflect`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ One final test you can do before you submit your series is to send everything to yourself. This is especially useful when using the web endpoint, because this allows you to see what the messages will look like after being potentially post-processed on the remote end. When ``--reflect`` is on: * b4 will still populate the To:/Cc: headers with all the addresses, because this allows to check for any encoding problems * b4 will **only send the series to the address in the From: field** * when using the web endpoint, the messages will not be added to the public-inbox feed * your branch will **not** be automatically rerolled to the next revision Checking things over with ``--preview-to`` **(v0.13+)** ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Sometimes you want to ask your friend, colleague, boss, or mentor to give your submission a quick review. You can send them your series using ``--preview-to boss@example.com`` before you send things out to the actual maintainers. When ``--preview-to`` is on: * b4 will **only send to the addresses you specify on the command line** * your branch will **not** be automatically rerolled to the next revision (NB: the web submission endpoint cannot currently be used for this feature.) What happens after you send --------------------------- The following happens after you send your patches: * b4 will automatically create a detached head containing the commits from your sent series and tag it with the contents of the cover letter; this creates a historical record of your submission, as well as adds a way to easily resend a previously sent series * b4 will reroll your series to the next version, so that if you just sent off a ``v1`` of the series, the working version will be marked as ``v2`` * b4 will automatically edit the cover letter to add templated changelog entries containing a pre-populated link to the just-sent series Resending your series ~~~~~~~~~~~~~~~~~~~~~ If something went wrong, or if you need to resend the series because nobody paid attention to it the first time, it is easy to do this with ``--resend vN``. B4 will automatically generate the series from the tagged historical version created during the previous sending attempt. Command line flags ------------------ ``-d, --dry-run`` Don't send any mail, just output the raw messages that would be sent. Normally, this is a wall of text, so you'd want to use ``-o`` instead. ``-o OUTPUT_DIR, --output-dir OUTPUT_DIR`` Prepares everything for sending, but writes out the messages into the folder specified instead. This is usually a good last check before actually sending things out and lets you verify that all patches are looking good and all recipients are correctly set. ``--preview-to`` **(v0.13+)** Sometimes it is useful to send your series for a pre-review to a colleague, mentor, boss, etc. Using this option will send out the series to the addresses specified on the command line, but will not reroll your series, allowing you to send the actual submission at some later point. ``--reflect`` **(v0.11+)** Prepares everything for sending, but only emails yourself (the address in the ``From:`` header). Useful as a last check to make sure that everything is looking good, and especially useful when using the web endpoint, because it may rewrite your From: header for DMARC reasons. ``--no-trailer-to-cc`` Do not add any addresses found in the cover or patch trailers to To: or Cc:. This is usually handy for testing purposes, in case you want to send a set of patches to a test address (also see ``--reflect``). ``--to`` Add any more email addresses to include into the To: header here (comma-separated). Can be set in the configuration file using the ``b4.send-series-to`` option (see :ref:`contributor_settings`). ``--cc`` Add any more email addresses to include into the Cc: header here (comma-separated). Can be set in the configuration file using the ``b4.send-series-cc`` option (see :ref:`contributor_settings`). ``--not-me-too`` Removes your own email address from the recipients. ``--no-sign`` Don't sign your patches with your configured attestation mechanism. Note, that patch signing is required for the web submission endpoint, so this is only a valid option to use with ``-o`` or when using your own SMTP server. This can be set in the configuration using the ``b4.send-no-patatt-sign`` (see :ref:`contributor_settings`). ``--resend V`` Resend a previously sent version (see above for more info). b4-0.13.0/docs/contributor/trailers.rst000066400000000000000000000025741456345453500200070ustar00rootroot00000000000000trailers: retrieving code-review trailers ========================================= This commands allows you to easily retrieve code-review trailers sent in reply to your work and apply them to the matching commits. It should locate code-review trailers sent in response to any previously submitted versions of your series, as long as: * either the patch-id of the commit still matches what was sent, or * the title of the commit is exactly the same You can always edit the trailers after they are applied by using ``git rebase -i`` and choosing ``reword`` as rebase action. Most commonly, you just need to run:: b4 trailers -u Command flags ------------- ``-u, --update`` Update branch commits with latest received trailers. ``-S, --sloppy-trailers`` Accept trailers where the email address of the sender differs from the email address found in the trailer itself. ``-F MSGID, --trailers-from MSGID`` Look for trailer updates in an arbitrary tread found on the public-inbox server. Note, that this is generally only useful in the following two cases: * for branches not already managed by ``b4 prep`` * when a single larger series is broken up into multiple smaller series (or vice-versa) ``--since SINCE`` Only useful with ``-F``. By default, b4 will only look for your own commits as far as 1 month ago. With this flag, you can instruct it to look further back. b4-0.13.0/docs/index.rst000066400000000000000000000020501456345453500147040ustar00rootroot00000000000000B4 end-user documentation ========================= B4 is a tool created to make it easier for project developers and maintainers to use a distributed development workflow that relies on patches and distribution lists for code contributions and review. This documentation is split into two main areas -- one aimed at maintainers who primarily receive, review, and apply patches to their trees, and the other at developers who submit patches or patch series for maintainer review. Installation and configuration ------------------------------ .. toctree:: :maxdepth: 1 installing config For maintainers --------------- .. toctree:: :maxdepth: 1 maintainer/overview maintainer/mbox maintainer/am-shazam maintainer/diff maintainer/pr maintainer/ty maintainer/kr For developers -------------- .. toctree:: :maxdepth: 1 contributor/overview contributor/prep contributor/send contributor/trailers Getting help ------------ To report a problem or suggest a feature, please send plaintext email to tools@kernel.org. b4-0.13.0/docs/installing.rst000066400000000000000000000035471456345453500157550ustar00rootroot00000000000000Installing b4 ============= B4 is packaged for many distributions, so chances are that you will be able to install it using your regular package installation commands, e.g.:: # dnf install b4 or:: # apt install b4 Note, that b4 is under heavy development, so it is possible that the version packaged for your distribution is not as recent as you'd like. If that is the case, you can install it from other sources. Installing with pip ------------------- To install from PyPi:: python3 -m pip install --user b4 This will install b4 locally and pull in any required dependencies. If you are not able to execute ``b4 --version`` after pip completes, check that your ``~/.local/bin/`` is in your ``$PATH``. Upgrading ~~~~~~~~~ If you have previously installed from PyPi, you can upgrade using pip as well:: python3 -m pip install --user --upgrade b4 Running from the checkout dir ----------------------------- If you want to run the latest development version of b4, you can run it directly from the git repository:: git clone https://git.kernel.org/pub/scm/utils/b4/b4.git cd b4 git submodule update --init pip install --user -r requirements.txt You can then either symlink the ``b4.sh`` script to your user-bin directory:: ln -sf $HOME/path/to/b4.sh ~/bin/b4 or you can add an alias to your shell's RC file:: alias b4="$HOME/path/to/b4/b4.sh" Using a stable branch ~~~~~~~~~~~~~~~~~~~~~ If you don't want to use the master branch (which may not be stable), you can switch to a stable branch instead, e.g.:: git switch stable-0.9.y Updating the git checkout ~~~~~~~~~~~~~~~~~~~~~~~~~ It should be sufficient to just turn ``git pull``:: git pull origin master git submodule update If you notice that ``requirements.txt`` has been updated, you may wish to run the pip command again:: pip install --user -r requirements.txt b4-0.13.0/docs/maintainer/000077500000000000000000000000001456345453500151755ustar00rootroot00000000000000b4-0.13.0/docs/maintainer/am-shazam.rst000066400000000000000000000245231456345453500176130ustar00rootroot00000000000000am,shazam: retrieving and applying patches ========================================== Most commonly, b4 is used to retrieve, prepare, and apply patches sent via distribution lists. The base functionality is similar to that of ``b4 mbox``:: b4 am 20200313231252.64999-1-keescook@chromium.org This will do the following: 1. look up if that message-id is known on the specified public-inbox server (e.g. lore.kernel.org) 2. retrieve the full thread containing that message-id 3. process all replies to collect code review trailers and apply them to the relevant patch commit messages 4. perform attestation checks on patches and code review follow-ups 5. put all patches in the correct order and prepare for "git am" 6. write out the resulting mailbox so it is ready to be applied to a git tree For example:: $ b4 am 20200313231252.64999-1-keescook@chromium.org Analyzing 5 messages in the thread Checking attestation on all messages, may take a moment... --- ✓ [PATCH v2 1/2] selftests/harness: Move test child waiting logic ✓ [PATCH v2 2/2] selftests/harness: Handle timeouts cleanly --- ✓ Signed: DKIM/chromium.org --- Total patches: 2 --- Cover: ./v2_20200313_keescook_selftests_harness_handle_timeouts_cleanly.cover Link: https://lore.kernel.org/r/20200313231252.64999-1-keescook@chromium.org Base: not specified git am ./v2_20200313_keescook_selftests_harness_handle_timeouts_cleanly.mbx b4 am vs. b4 shazam ------------------- .. note:: ``b4 shazam`` was added in version v0.9. The two commands are very similar -- the main distinction is that ``b4 am`` will prepare the patch series for application to the git tree, but will not make any modifications to your current branch. The ``b4 shazam`` command will do the same as ``b4 am`` *and* will apply the patch series to the current branch (if it is possible to do so cleanly). Common flags ------------ The following flags are common to both commands: ``-m LOCALMBOX, --use-local-mbox LOCALMBOX`` By default, b4 will retrieve threads from remote public-inbox servers, but it can also use a local mailbox/maildir. This is useful if you have a tool like ``mbsync`` or ``lei`` copying remote messages locally and you need to do some work while offline. You can pass ``-`` to read messages from stdin. ``--stdin-pipe-sep STDIN_PIPE_SEP`` **(0.11+)** When reading input from stdin, split messages using the string passed as parameter. Otherwise, b4 expects stdin to be a single message or a valid mbox. This is most useful when piping threads directly from mutt. In your ``.muttrc`` add the following configuration parameter:: set pipe_sep = "\n---randomstr---\n" Then invoke b4 with ``-m - --stdin-pipe-sep='\n---randomstr---\n'`` ``-C, --no-cache`` By default, b4 will cache the retrieved threads for about 10 minutes. This lets you force b4 to ignore cache and retrieve the latest results. ``--single-message`` **(0.13+)** By default, b4 will retrieve the entire thread, but sometimes you really just want a single message. This helps when someone posts a patch in the middle of a long thread and you just want that patch and ignore the rest of what is going on. ``-v WANTVER, --use-version WANTVER`` If a thread (or threads, when used with ``-c``) contains multiple patch series revisions, b4 will automatically pick the highest numbered version. This switch lets you pick a different revision. ``-S, --sloppy-trailers`` B4 tries to be careful when collecting code review trailers and will refuse to consider the trailers where the email address in the From: header does not patch the address in the trailer itself. For example, the following message will not be processed:: From: Alice Maintainer Subject: Re: [PATCH v3 3/3] Some patch title > [...] Reviewed-by: Alice Maintainer In such situations, b4 will print a warning and refuse to apply the trailer due to the email address mismatch. You can override this by passing the ``-S`` flag. ``-T, --no-add-trailers`` This tells b4 to ignore any follow-up trailers and just save the patches as sent by the contributor. ``-s, --add-my-sob`` Applies your own ``Signed-off-by:`` trailer to every commit. ``-l, --add-link`` Adds a ``Link:`` trailer with the URL of the retrieved message using the ``linkmask`` template. Note, that such trailers may be considered redundant by the upstream maintainer. ``-P CHERRYPICK, --cherry-pick CHERRYPICK`` This allows you to select a subset of patches from a larger series. Here are a few examples. This will pick patches 1, 3, 5, 6, 7, 9 and any others that follow:: b4 am -P 1,3,5-7,9- This will pick just the patch that matches the exact message-id provided:: b4 am -P _ This will pick all patches where the subject matches "iscsi":: b4 am -P *iscsi* ``--cc-trailers`` Copies all addresses found in the message Cc's into ``Cc:`` commit trailers. ``--no-parent`` Break thread at the msgid specified and ignore any parent messages. This is handy with very convoluted threads, for example when someone replies with a different patch series in the middle of a larger conversation and b4 gets confused about which patch series is being requested. ``--allow-unicode-control-chars`` There are some clever tricks that can be accomplished with unicode control chars that make the code as printed on the screen (and reviewed by a human) to actually do something totally different when processed by a compiler. Such unicode control chars are almost never legitimately useful in the code, so b4 will print a warning and bail out when it finds them. However, just in case there are legitimate reasons for these characters to be in the code (e.g. as part of documentation translated into LTR languages), this behaviour can be overridden. Flags only valid for ``b4 am`` ------------------------------ The following flags only make sense for ``b4 am``: ``-o OUTDIR, --outdir OUTDIR`` Instead of writing the .mbox file to the current directory, write it to this location instead. You can also pass a path to an existing mbox or maildir location to have the results appended to that mailbox instead (see also the ``-f`` flag below). When ``-`` is specified, the output is dumped to stdout. ``-c, --check-newer-revisions`` When retrieving patch series, check if a newer revision is available. For example, if you are trying to retrieve a series titled ``[PATCH v2 0/3]``, b4 will use a number of mechanisms to check if a ``v3`` or later revision is also available and will add these results to the retrieved thread. ``-n WANTNAME, --mbox-name WANTNAME`` By default, the resulting mailbox file will use the message-id as the basis for its filename. This option lets you override this behaviour. ``-M, --save-as-maildir`` By default, the retrieved thread will be saved as an mbox file. However, due to subtle incompatibilities between various mbox formats ("mboxo" vs "mboxrd", etc), you may want to instead save the results as a Maildir directory. ``-Q, --quilt-ready`` Saves the patches as a folder that can be fed directly to quilt. If you don't know what quilt is, you don't really need to worry about this option. ``-b GUESSBRANCH [...], --guess-branch GUESSBRANCH [...]`` When using ``--guess-base``, you can restrict which branch(es) b4 will use to find the match. If not specified, b4 will use the entire tree history. ``--guess-lookback GUESSDAYS`` When using ``--guess-base``, you can specify how far back b4 should look *from the date of the patch* to find the base commit. By default, b4 will only consider the last 14 days prior to the date of the patch, but you can expand or shrink it as necessary. ``-3, --prep-3way`` This will try to prepare your tree for a 3-way merge by doing some behind the scenes git magic and preparing some fake loose commits. ``--no-cover`` By default, b4 will save the cover letter as a separate file in the output directory specified. This flag turns it off (this is also the default when used with ``-o -``). ``--no-partial-reroll`` For minor changes, it is common practice for contributors to send follow-ups to just the patches they have modified. For example:: [PATCH v1 1/3] foo: add foo to bar [PATCH v1 2/3] bar: add bar to baz \- [PATCH v2 2/3] bar: add bar to baz [PATCH v1 3/3] baz: add baz to quux In this case, b4 will properly create a v2 of the entire series by reusing ``[PATCH v1 1/3]`` and ``[PATCH v1 3/3]``. However, sometimes that is not the right thing to do, so you can turn off this feature using ``--no-partial-reroll``. Flags only valid for ``b4 shazam`` ---------------------------------- By default, ``b4 shazam`` will apply the patch series directly to the git tree where the command is being executed. However, instead of just running ``git am`` and applying the patches directly on top of the current branch, it can also treat the series similar to a git pull request and either prepare a ``FETCH_HEAD`` that you can merge manually, or even automatically merge the series using the series cover letter as the basis for the merge commit. ``-H, --make-fetch-head`` This will prepare the series and place it into the ``FETCH_HEAD`` that can then be merged just as if it were a pull request: 1. b4 will prepare a temporary sparse worktree 2. b4 will apply the series to that worktree 3. if ``git am`` completed successfully, b4 will fetch that tree into your current tree's ``FETCH_HEAD`` (and get rid of the temporary tree) 4. b4 will place the cover letter into ``.git/b4-cover`` 5. b4 will offer the command you can run to merge the change into your current branch, e.g.:: git merge --no-ff -F .git/b4-cover --edit FETCH_HEAD --signoff Generally, this command is also a good test for "will this patch series apply cleanly to my tree." You can perform any actions with the ``FETCH_HEAD`` as you normally would, e.g. run ``git diff``, make a new branch out of it using ``git checkout``, etc. ``-M, --merge`` Exactly the same as ``--make-fetch-head``, but will actually execute the suggested ``git merge`` command. Please also see the :ref:`shazam_settings` section for some configuration file options that affect some of ``b4 shazam`` behaviour. b4-0.13.0/docs/maintainer/diff.rst000066400000000000000000000040571456345453500166450ustar00rootroot00000000000000diff: comparing patch series ============================ The ``diff`` subcommand allows comparing two different revisions of the same patch series using ``git range-diff``. Note, that in order to perform the ``range-diff`` comparison, both revisions need to cleanly apply to the current tree, which may not always be easy to achieve. The easiest way to use it is to prepare two mbox files of the series you would like to compare first:: $ b4 am --no-cover -n ver1 msgid-of-ver-1 $ b4 am --no-cover -n ver2 msgid-of-ver-2 $ b4 diff -m ver1.mbx ver2.mbx Optional flags -------------- ``-g GITDIR, --gitdir GITDIR`` Specify a path to the git tree to use, if not running the command inside a git tree. ``-C, --no-cache`` By default, b4 will cache the retrieved threads for about 10 minutes. This lets you force b4 to ignore cache and retrieve the latest results. ``-v WANTVERS [WANTVERS ...], --compare-versions WANTVERS [WANTVERS ...]`` To properly work, this requires that both versions being compared are part of the same thread, which is rarely the case. In the future, this may work better as more series use the ``change-id`` trailer to keep track of revisions across discussion threads. Example: ``b4 diff -v 2 3`` ``-n, --no-diff`` By default, ``b4 diff`` will output the results of the range-diff command. However, this can be a wall of text, so instead you may want to just view the command that you can run yourself with the ranges prepared by b4. This additionally allows you to tweak the ``git-range`` flags to use. ``-m AMBOX AMBOX, --compare-am-mboxes AMBOX AMBOX`` Compares two mbox files prepared by ``git am`` instead of querying the public-inbox server directly. ``-o OUTDIFF, --output-diff OUTDIFF`` **(DEPRECATED)** Sends ``range-diff`` output into a file. You should use ``-n`` instead and redirect output from the actual ``git range-diff`` command. ``-c, --color`` **(DEPRECATED)** Show colour output even when outputting into a file. You should use ``-n`` instead and modify flags to ``range-diff``. b4-0.13.0/docs/maintainer/kr.rst000066400000000000000000000032431456345453500163450ustar00rootroot00000000000000kr: working with contributor keys ================================= This subcommand allows maintaining a local keyring of contributor keys. .. note:: This functionality is under active development and the set of available features will be expanded in the near future. Patatt keyrings --------------- B4 uses the patatt patch attestation library for its purposes, and it uses patatt-style keyrings. You can read more information about managing patatt keyrings at the following page: * https://pypi.org/project/patatt/#getting-started-as-a-project-maintainer b4 kr --show-keys ----------------- At this stage, b4 has limited support for keyring management, but there are plans to expand this functionality in one of the future versions. At most, you can view what keys were used to sign a set of patches in a thread, e.g.:: $ b4 kr --show-keys Grabbing thread from lore.kernel.org/all//t.mbox.gz --- alice.developer@example.org: (unknown) keytype: ed25519 pubkey: AbCdzUj91asvincQGOFx6+ZF5AoUuP9GdOtQChs7Mm0= krpath: ed25519/example.org/alice.developer/20211009 fullpath: /home/user/.local/share/b4/keyring/ed25519/example.org/alice.developer/20211009 --- For ed25519 keys: echo [pubkey] > [fullpath] At this time, if you want to store this public key in your local keyring, you can run the command suggested above:: echo AbCdzUj91asvincQGOFx6+ZF5AoUuP9GdOtQChs7Mm0= > \ /home/user/.local/share/b4/keyring/ed25519/example.org/alice.developer/20211009 Now if you come across a signed set of patches from alice.developer, you should be able to view the attestation status in the ``b4 am`` output. b4-0.13.0/docs/maintainer/mbox.rst000066400000000000000000000112061456345453500166740ustar00rootroot00000000000000mbox: retrieving threads ======================== .. note:: If you are looking for a way to continuously retrieve full threads (or even full search results) from a public-inbox server, the ``lei`` tool provides a much more robust way of doing that. Retrieving full discussion threads is the most basic use of b4. All you need to know is the message-id of any message in the thread:: b4 mbox 20200313231252.64999-1-keescook@chromium.org Alternatively, if you have found a thread on lore.kernel.org and you want to retrieve it in full, you can just use the full URL:: b4 mbox https://lore.kernel.org/lkml/20200313231252.64999-1-keescook@chromium.org/#t By default, b4 will save the thread in a mailbox format using the message-id of the message as the filename base:: $ b4 mbox 20200313231252.64999-1-keescook@chromium.org Grabbing thread from lore.kernel.org/all/20200313231252.64999-1-keescook%40chromium.org/t.mbox.gz 5 messages in the thread Saved ./20200313231252.64999-1-keescook@chromium.org.mbx Option flags ------------ ``-m LOCALMBOX, --use-local-mbox LOCALMBOX`` By default, b4 will retrieve threads from remote public-inbox servers, but it can also use a local mailbox/maildir. This is useful if you have a tool like ``mbsync`` or ``lei`` copying remote messages locally and you need to do some work while offline. You can pass ``-`` to read messages from stdin. ``--stdin-pipe-sep STDIN_PIPE_SEP`` **(0.11+)** When reading input from stdin, split messages using the string passed as parameter. Otherwise, b4 expects stdin to be a single message or a valid mbox. This is most useful when piping threads directly from mutt. In your ``.muttrc`` add the following configuration parameter:: set pipe_sep = "\n---randomstr---\n" Then invoke b4 with ``-m - --stdin-pipe-sep='\n---randomstr---\n'`` ``-C, --no-cache`` By default, b4 will cache the retrieved threads for about 10 minutes. This lets you force b4 to ignore cache and retrieve the latest results. ``--single-message`` **(0.13+)** By default, b4 will retrieve the entire thread, but sometimes you really just want a single message. This helps when someone posts a patch in the middle of a long thread and you just want that patch and ignore the rest of what is going on. ``-o OUTDIR, --outdir OUTDIR`` Instead of writing the .mbox file to the current directory, write it to this location instead. You can also pass a path to an existing mbox or maildir location to have the results appended to that mailbox instead (see also the ``-f`` flag below). ``-c, --check-newer-revisions`` When retrieving patch series, check if a newer revision is available. For example, if you are trying to retrieve a series titled ``[PATCH v2 0/3]``, b4 will use a number of mechanisms to check if a ``v3`` or later revision is also available and will add these results to the retrieved thread. ``-n WANTNAME, --mbox-name WANTNAME`` By default, the resulting mailbox file will use the message-id as the basis for its filename. This option lets you override this behaviour. ``-M, --save-as-maildir`` By default, the retrieved thread will be saved as an mbox file. However, due to subtle incompatibilities between various mbox formats ("mboxo" vs "mboxrd", etc), you may want to instead save the results as a Maildir directory. ``-f, --filter-dupes`` When adding messages to existing mailbox or maildir (with ``-o``), this will check all existing messages and will only add those messages that aren't already present. Note, that this uses simple message-id matching and no other checks for correctness are performed. ``-r MBOX, --refetch MBOX`` **(v0.12+)** This allows you to refetch all messages in the provided mailbox from the upstream public-inbox server. For example, this is useful when you have a .mbx file prepared by ``b4 am`` and you want to send a response to one of the patches. Performing a refetch will restore the original message headers that may have been dropped or modified by ``b4 am``. Using with mutt --------------- If you are a mutt or neomutt user and your mail is stored locally, you can define a quick macro that would let you quickly retrieve full threads and add them to your inbox. This is handy if you are cc'd in the middle of a conversation and you want to retrieve the rest of the thread for context. Add something like the following to your ``~/.muttrc``:: macro index 4 "b4 mbox -fo ~/Mail" Now selecting a message in the message index and pressing "4" will retrieve the rest of the thread from the public-inbox server and add them to the local maildir (``~/Mail`` in the example above). b4-0.13.0/docs/maintainer/overview.rst000066400000000000000000000025271456345453500176030ustar00rootroot00000000000000Maintainer overview =================== The primary goal of b4 is to make it easier for maintainers to retrieve patch series, verify their authenticity, apply any follow-up code review trailers, and apply the patches to their maintained git trees. This functionality works best when coupled with a `public-inbox`_ aggregator service, such as the one running on lore.kernel.org, but can be used with local mailboxes and maildirs, thus providing fully decentralized, experience with robust end-to-end attestation. .. _`public-inbox`: https://public-inbox.org/README.html Working with patches sent to distribution lists ----------------------------------------------- Patches sent to distribution lists remains the only widely used decentralized code review framework. RFC2822-conformant ("email") messages adhere to an established standard that ensures high level of interoperability between systems, and it remains one of the remaining few truly decentralized communication platforms. Note, that "distribution lists" may not necessarily mean "patches sent via email". In addition to SMTP, RFC2822 messages can be also delivered via any number of push and pull mechanisms, such as NNTP, web archives, public-inbox repositories, etc. In the case of lore.kernel.org, the messages are collated from a large number of sources then replicated across multiple frontends. b4-0.13.0/docs/maintainer/pr.rst000066400000000000000000000052041456345453500163510ustar00rootroot00000000000000pr: working with pull requests ============================== In addition to working with patches and patch series, b4 is also able to work with pull requests. It provides the following benefits as opposed to using git directly: * it can check if the pull request has already been applied before performing a git fetch * it will check the signature on the tag (or tip commit) * it can track applied pull requests and send replies to submitters (using ``b4 ty``) * it can explode a pull request into a series of patches for code review purposes Basic usage is very similar to ``b4 am``:: b4 pr By default, this will fetch the pull request into ``FETCH_HEAD``. Optional flags -------------- ``-g GITDIR, --gitdir GITDIR`` This specifies (or overrides) the git directory where the pull request should be applied. ``-b BRANCH, --branch BRANCH`` After fetching the pull request into ``FETCH_HEAD``, check it out as a new branch with the name specified. ``-c, --check`` Check if the specified pull request has already been applied. Exploding pull requests ----------------------- Pull requests are useful, but if the maintainer needs to do more than just accept or reject it, providing code review commentary on a PR can be difficult. For this reason, b4 can convert a pull request into a mailbox full of patches, as if the pull request was sent as a patch series. The exploded pull request will retain the correct author and To/Cc headers. ``-e, --explode`` Instructs b4 to convert a pull request to a series of patches and save them as a mailbox file. ``-o OUTMBOX, --output-mbox OUTMBOX`` If ``-o`` is not provided, the mailbox name will be based on the message-id of the pull request and saved in the local directory. This allows overriding that with a different path and name. Explode archival features ~~~~~~~~~~~~~~~~~~~~~~~~~ .. note:: These are experimental features that were developed for internal kernel.org use. The following flags are mostly useful when b4 is used for archival purposes. One of the goals of this feature was to make it possible to save pull requests, which are transient by nature, into an archival public-inbox so they can be analyzed by archivists at a later date if necessary. ``-f MAILFROM, --from-addr MAILFROM`` **(DEPRECATED)** When exploding pull requests, use this email address in the From header, instead of reusing the same From as in the pull request. ``-s SENDIDENTITY, --send-as-identity SENDIDENTITY`` **(DEPRECATED)** When resending pull requests as patch series, use this sendemail identity. ``--dry-run`` **(DEPRECATED)** Force a --dry-run on ``git-send-email`` invocation. b4-0.13.0/docs/maintainer/ty.rst000066400000000000000000000114401456345453500163630ustar00rootroot00000000000000ty: sending automated contributor feedback ========================================== B4 makes it easier to send automated developer feedback when you apply patches or pull requests to your git tree. Tracking retrieved patches and PRs ---------------------------------- Any patches or pull requests you retrieve with ``b4 am,shazam,pr`` will be automatically tracked by b4 in your homedir (usually, in ``$HOME/.local/share/b4``, but may vary if your ``$XDG_DATA_HOME`` is set to a different value). There are four kinds of files in that directory: * .am: contain information about patches retrieved with ``b4 am`` or ``b4 shazam`` * .pr: contain information about pull requests retrieved with ``b4 pr`` * .sent: either .am or .pr tracked files that were successfully sent using ``b4 ty`` * .discarded: either .am or .pr tracked files that were deleted using ``b4 ty`` All of these files contain JSON data about the series or pull requests being tracked. Using the Auto-Thankanator -------------------------- If you've retrieved and applied some patches to your tree, you should be able to fire up the "auto-thankanator", which uses patch-id and commit subject tracking to figure out which series from those you have retrieved were applied to your tree. The process is usually pretty fast and fairly accurate. Manually listing and thanking ----------------------------- If you don't want to use the auto-thankanator, or if it's not finding a patch series (e.g. because you've made changes to a commit before applying it to your tree), you can use a more manual process. First, list all tracked series:: $ b4 ty -l Identify the series that you're sure got applied, then generate the thank-you message:: $ b4 ty -t 1 This will write out a .thanks file in the current directory, which you can then modify and send out. Sending out mail vs. writing .thanks files ------------------------------------------ By default, ``b4 ty`` will write out .thanks files in the current directory, which allows you to edit the body of the message before sending it out, e.g. using ``mutt``:: $ mutt -f foo.thanks However, if you have a configured ``sendemail`` section, you can also tell b4 to send out the thanks message directly:: $ b4 ty -aS --dry-run The above command will locate all tracked series that got applied to your tree and show the messages that are going to be sent out. If you're happy with the results, you can omit the ``--dry-run`` switch to actually send the mail. If you want ``b4 ty`` to always send mail, you can make the ``-S`` switch permanent by setting the ``b4.ty-send-email`` config variable to ``yes``. Editing the templates --------------------- You can edit the default templates that are provided with b4 to customize the thank-you message. Once you have your own versions, you can specify the path to the template to use via the ``b4.thanks_am_template`` and ``b4.thanks_pr_template`` configuration parameters. See :ref:`ty_settings` for details. Optional flags -------------- ``-g GITDIR, --gitdir GITDIR`` The git tree to use instead of the current working directory. ``-o OUTDIR, --outdir OUTDIR`` Where to write the .thanks files if not into the current directory. Has no effect when ``-S`` is used. ``-l, --list`` Lists all tracked patch series and pull requests. ``-t THANKFOR, --thank-for THANKFOR`` From the listing generated by ``--list``, specify which thank-you notes should be sent. This command accepts comma-separated values and ranges, including open-ended ranges, e.g.: ``-t 1,3,5-7,9-``. It also accepts ``all``. ``-d DISCARD, --discard DISCARD`` From the listing generated by ``--list``, specify which thank-you notes should be discarded. This command accepts comma-separated values and ranges, including open-ended ranges, e.g.: ``-t 1,3,5-7,9-``. It also accepts ``all``. ``-a, --auto`` The auto-thankanator: uses patch-id and commit subject matching to figure out which tracked series or pull request have been applied to your tree. ``-b BRANCH, --branch BRANCH`` When using ``--auto``, specify which git branch should be used if not the currently active branch. ``--since SINCE`` When using ``--auto``, this lets you adjust how far back b4 will look to find your own commits. Takes the same format as ``--since`` flags passed to git, with the default of ``1.week``. ``-S, --send-email`` Instead of writing .thanks files, send the email directly. Requires that the ``sendemail`` section is present in your git configuration. ``--dry-run`` When used with ``-S``, will not actually send email, just print them out to stdout. ``--pw-set-state PW_SET_STATE`` When patchwork integration is configured, sets the specified patchwork state instead of the default specified in config settings (use with -a, -t or -d). See :ref:`patchwork_settings` for more details. b4-0.13.0/docs/requirements.txt000066400000000000000000000002061456345453500163300ustar00rootroot00000000000000# Defining the exact version will make sure things don't break sphinx==5.3.0 sphinx_rtd_theme==1.1.1 readthedocs-sphinx-search==0.1.1 b4-0.13.0/man/000077500000000000000000000000001456345453500126715ustar00rootroot00000000000000b4-0.13.0/man/b4.5000066400000000000000000000543111456345453500132700ustar00rootroot00000000000000.\" Man page generated from reStructuredText. . . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .TH "B4" 5 "2024-02-15" "0.13.0" "" .SH NAME B4 \- Work with code submissions in a public-inbox archive .SH SYNOPSIS .sp b4 {mbox,am,shazam,pr,diff,ty,kr,prep,send,trailers} [options] .SH DESCRIPTION .sp This is a helper utility to work with patches and pull requests made available via a public\-inbox archive like lore.kernel.org. It is written to make it easier to participate in a patch\-based workflows, like those used in the Linux kernel development. .sp The name \(dqb4\(dq was chosen for ease of typing and because B\-4 was the precursor to Lore and Data in the Star Trek universe. .sp Full documentation is available on \fI\%https://b4.docs.kernel.org/\fP\&. .SH SUBCOMMANDS .sp Maintainer\-oriented: .INDENT 0.0 .IP \(bu 2 \fImbox\fP: Download a thread as an mbox file .IP \(bu 2 \fIam\fP: Create an mbox file that is ready to git\-am .IP \(bu 2 \fIshazam\fP: Apply patch series to git repositories .IP \(bu 2 \fIpr\fP: Work with pull requests .IP \(bu 2 \fIdiff\fP: Show range\-diff style diffs between patch versions .IP \(bu 2 \fIty\fP: Create templated replies for processed patches and pull requests .IP \(bu 2 \fIkr\fP: (EXPERIMENTAL) Operate on patatt\-compatible keyrings .UNINDENT .sp Contributor\-oriented: .INDENT 0.0 .IP \(bu 2 \fIprep\fP: (EXPERIMENTAL) prepare your series for submission .IP \(bu 2 \fIsend\fP: (EXPERIMENTAL) send your series for review on distribution lists .IP \(bu 2 \fItrailers\fP: (EXPERIMENTAL) retrieve and apply code\-review trailers .UNINDENT .SH OPTIONS .INDENT 0.0 .TP .B \-h\fP,\fB \-\-help show this help message and exit .TP .B \-d\fP,\fB \-\-debug Add more debugging info to the output (default: False) .TP .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) .TP .B \-\-offline\-mode Do not perform any network queries (default: False) .TP .B \-\-no\-stdin Disable TTY detection for stdin (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] [\-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 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 .BI \-\-stdin\-pipe\-sep \ STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string .TP .B \-C\fP,\fB \-\-no\-cache Do not use local cache .TP .B \-\-single\-message Only retrieve the message matching the msgid and ignore the rest of the thread .TP .BI \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR Output into this directory (or use \- to output mailbox contents to stdout) .TP .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 destination .TP .B \-M\fP,\fB \-\-save\-as\-maildir Save as maildir (avoids mbox format ambiguities) .TP .B \-f\fP,\fB \-\-filter\-dupes When adding messages to existing maildir, filter out duplicates .TP .BI \-r \ MBOX\fR,\fB \ \-\-refetch \ MBOX Refetch all messages in specified mbox with their original headers .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 \(dqgit am\(dq 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] [\-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 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 .BI \-\-stdin\-pipe\-sep \ STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string .TP .B \-C\fP,\fB \-\-no\-cache Do not use local cache .TP .B \-\-single\-message Only retrieve the message matching the msgid and ignore the rest of the thread .TP .BI \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR Output into this directory (or use \- to output mailbox contents to stdout) .TP .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 destination .TP .B \-M\fP,\fB \-\-save\-as\-maildir Save as maildir (avoids mbox format ambiguities) .TP .B \-\-single\-message Only retrieve the message matching the msgid and ignore the rest of the thread .TP .BI \-v \ WANTVER\fR,\fB \ \-\-use\-version \ WANTVER Get a specific version of the patch/series .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. \(dq\-P 1\-2,4,6\-\(dq, \(dq\-P _\(dq to use just the msgid specified, or \(dq\-P \fIglobbing\fP\(dq 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 \-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 \-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 \-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 Do not save the cover letter (on by default when using \-o \-) .TP .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 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 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: 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 .BI \-\-stdin\-pipe\-sep \ STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string .TP .B \-C\fP,\fB \-\-no\-cache Do not use local cache .TP .B \-\-single\-message Only retrieve the message matching the msgid and ignore the rest of the thread .TP .BI \-v \ WANTVER\fR,\fB \ \-\-use\-version \ WANTVER Get a specific version of the patch/series .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. \(dq\-P 1\-2,4,6\-\(dq, \(dq\-P _\(dq to use just the msgid specified, or \(dq\-P \fIglobbing\fP\(dq 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) .TP .BI \-\-merge\-base \ COMMIT (use with \-H or \-M) Force this base when merging .UNINDENT .UNINDENT .sp \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: b4 pr [\-h] [\-g GITDIR] [\-b BRANCH] [\-c] [\-e] [\-o OUTMBOX] [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 show this help message and exit .TP .BI \-g \ GITDIR\fR,\fB \ \-\-gitdir \ GITDIR Operate on this git tree instead of current dir .TP .BI \-b \ BRANCH\fR,\fB \ \-\-branch \ BRANCH Check out FETCH_HEAD into this branch after fetching .TP .B \-c\fP,\fB \-\-check Check if pull request has already been applied .TP .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 .BI \-f \ MAILFROM\fR,\fB \ \-\-from\-addr \ MAILFROM Use this From: in exploded messages (use with \-e) .UNINDENT .UNINDENT .sp \fIExample\fP: b4 pr \fI\%202003292120.2BDCB41@keescook\fP .SS b4 ty .INDENT 0.0 .TP .B usage: 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 show this help message and exit .TP .BI \-g \ GITDIR\fR,\fB \ \-\-gitdir \ GITDIR Operate on this git tree instead of current dir .TP .BI \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR Write thanks files into this dir (default=.) .TP .B \-l\fP,\fB \-\-list List pull requests and patch series you have retrieved .TP .BI \-t \ THANK_FOR\fR,\fB \ \-\-thank\-for \ THANK_FOR Generate thankyous for specific entries from \-l (e.g.: 1,3\-5,7\-; or \(dqall\(dq) .TP .BI \-d \ DISCARD\fR,\fB \ \-\-discard \ DISCARD Discard specific messages from \-l (e.g.: 1,3\-5,7\-; or \(dqall\(dq) .TP .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 .TP .BI \-\-pw\-set\-state \ PW_STATE Set this patchwork state instead of default (use with \-a, \-t or \-d) .UNINDENT .UNINDENT .sp \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 \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 .sp optional arguments: .INDENT 0.0 .INDENT 3.5 .INDENT 0.0 .TP .B \-h\fP,\fB \-\-help show this help message and exit .TP .BI \-g \ GITDIR\fR,\fB \ \-\-gitdir \ GITDIR Operate on this git tree instead of current dir .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 \-\-no\-cache Do not use local cache .UNINDENT .INDENT 0.0 .TP .B \-v WANTVERS [WANTVERS ...], \-\-compare\-versions WANTVERS [WANTVERS ...] 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 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 Force color output even when writing to file .UNINDENT .INDENT 0.0 .TP .B \-m AMBOX AMBOX, \-\-compare\-am\-mboxes AMBOX AMBOX Compare two mbx files prepared with \(dqb4 am\(dq .UNINDENT .UNINDENT .UNINDENT .sp \fIExample\fP: b4 diff \fI\%20200526205322.23465\-1\-mic@digikod.net\fP .SS b4 kr .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 show this help message and exit .TP .BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT Use a specific project instead of guessing (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 .BI \-\-stdin\-pipe\-sep \ STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string .TP .B \-C\fP,\fB \-\-no\-cache Do not use local cache .TP .B \-\-show\-keys Show all developer keys from the thread .UNINDENT .UNINDENT .sp \fIExample\fP: b4 kr \-\-show\-keys \fI\%20210521184811.617875\-1\-konstantin@linuxfoundation.org\fP .SS b4 prep .INDENT 0.0 .TP .B usage: b4 prep [\-h] [\-c | \-p OUTPUT_DIR | \-\-edit\-cover | \-\-show\-revision | \-\-force\-revision N | \-\-compare\-to vN | \-\-manual\-reroll COVER_MSGID | \-\-set\-prefixes PREFIX [PREFIX ...] | \-\-show\-info [PARAM] ] | [ \-\-cleanup [BRANCHNAME] ] [\-n NEW_SERIES_NAME] [\-f FORK_POINT] [\-F MSGID] [\-e ENROLL_BASE] .TP .B options: .INDENT 7.0 .TP .B \-h\fP,\fB \-\-help show this help message and exit .TP .B \-c\fP,\fB \-\-auto\-to\-cc Automatically populate cover letter trailers with To and Cc addresses .TP .BI \-p \ OUTPUT_DIR\fR,\fB \ \-\-format\-patch \ OUTPUT_DIR Output prep\-tracked commits as patches .TP .B \-\-edit\-cover Edit the cover letter in your defined $EDITOR (or core.editor) .TP .B \-\-show\-revision Show current series revision number .TP .BI \-\-force\-revision \ N Force revision to be this number instead .TP .BI \-\-compare\-to \ vN Display a range\-diff to previously sent revision N .TP .BI \-\-manual\-reroll \ COVER_MSGID Mark current revision as sent and reroll (requires cover letter msgid) .UNINDENT .INDENT 7.0 .TP .B \-\-set\-prefixes PREFIX [PREFIX ...] Extra prefixes to add to [PATCH] (e.g.: RFC mydrv) .TP .B \-\-show\-info [PARAM] Show series info in a format that can be passed to other tools .TP .B \-\-cleanup [BRANCHNAME] Archive and delete obsolete prep\-managed branches .UNINDENT .TP .B Create new branch: Create a new branch for working on patch series .INDENT 7.0 .TP .BI \-n \ NEW_SERIES_NAME\fR,\fB \ \-\-new \ NEW_SERIES_NAME Create a new branch for working on a patch series .TP .BI \-f \ FORK_POINT\fR,\fB \ \-\-fork\-point \ FORK_POINT When creating a new branch, use this fork point instead of HEAD .TP .BI \-F \ MSGID\fR,\fB \ \-\-from\-thread \ MSGID When creating a new branch, use this thread .UNINDENT .TP .B Enroll existing branch: Enroll existing branch for prep work .INDENT 7.0 .TP .BI \-e \ ENROLL_BASE\fR,\fB \ \-\-enroll \ ENROLL_BASE Enroll current branch, using the passed tag, branch, or commit as fork base .UNINDENT .UNINDENT .SS b4 send .INDENT 0.0 .TP .B usage: b4 send [\-h] [\-d | \-o OUTPUT_DIR | \-\-preview\-to ADDR [ADDR ...] | \-\-reflect] [\-\-no\-trailer\-to\-cc] [\-\-to ADDR [ADDR ...]] [\-\-cc ADDR [ADDR ...]] [\-\-not\-me\-too] [\-\-resend [vN]] [\-\-no\-sign] [\-\-web\-auth\-new] [\-\-web\-auth\-verify VERIFY_TOKEN] .TP .B options: .INDENT 7.0 .TP .B \-h\fP,\fB \-\-help show this help message and exit .TP .B \-d\fP,\fB \-\-dry\-run Do not send, just dump out raw smtp messages to the stdout .UNINDENT .INDENT 7.0 .TP .B \-\-preview\-to ADDR [ADDR ...] Send everything for a pre\-review to specified addresses instead of actual recipients .UNINDENT .INDENT 7.0 .TP .BI \-o \ OUTPUT_DIR\fR,\fB \ \-\-output\-dir \ OUTPUT_DIR Do not send, write raw messages to this directory (forces \-\-dry\-run) .TP .B \-\-reflect Send everything to yourself instead of the actual recipients .TP .B \-\-no\-trailer\-to\-cc Do not add any addresses found in the cover or patch trailers to To: or Cc: .UNINDENT .INDENT 7.0 .TP .B \-\-to ADDR [ADDR ...] Addresses to add to the To: list .TP .B \-\-cc ADDR [ADDR ...] Addresses to add to the Cc: list .UNINDENT .INDENT 7.0 .TP .B \-\-not\-me\-too Remove yourself from the To: or Cc: list .UNINDENT .INDENT 7.0 .TP .B \-\-resend [vN] Resend a previously sent version of the series .UNINDENT .INDENT 7.0 .TP .B \-\-no\-sign Do not add the cryptographic attestation signature header .UNINDENT .TP .B Web submission: Authenticate with the web submission endpoint .INDENT 7.0 .TP .B \-\-web\-auth\-new Initiate a new web authentication request .TP .BI \-\-web\-auth\-verify \ VERIFY_TOKEN Submit the token received via verification email .UNINDENT .UNINDENT .SS b4 trailers .INDENT 0.0 .TP .B usage: b4 trailers [\-h] [\-u] [\-S] [\-F MSGID] [\-\-since SINCE] .TP .B options: .INDENT 7.0 .TP .B \-h\fP,\fB \-\-help show this help message and exit .TP .B \-u\fP,\fB \-\-update Update branch commits with latest received trailers .TP .B \-S\fP,\fB \-\-sloppy\-trailers Apply trailers without email address match checking .TP .BI \-F \ MSGID\fR,\fB \ \-\-trailers\-from \ MSGID Look for trailers in the thread with this msgid instead of using the series change\-id .TP .BI \-\-since \ SINCE The \-\-since option to use with \-F when auto\-matching patches (default=1.month) .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 .BI \-\-stdin\-pipe\-sep \ STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string .TP .B \-C\fP,\fB \-\-no\-cache Do not use local cache .UNINDENT .UNINDENT .SH CONFIGURATION .sp B4 configuration is handled via git\-config(1), so you can store it in either the toplevel $HOME/.gitconfig file, or in a per\-repository \&.git/config file if your workflow changes per project. .sp To see configuration options available, see online documentation at \fI\%https://b4.docs.kernel.org/en/latest/config.html\fP .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@kernel.org\fP with support requests, or browse the list archive at \fI\%https://lore.kernel.org/tools\fP\&. .SH AUTHOR mricon@kernel.org License: GPLv2+ .SH COPYRIGHT The Linux Foundation and contributors .\" Generated by docutils manpage writer. . b4-0.13.0/man/b4.5.rst000066400000000000000000000506441456345453500141040ustar00rootroot00000000000000B4 == ---------------------------------------------------- Work with code submissions in a public-inbox archive ---------------------------------------------------- :Author: mricon@kernel.org :Date: 2024-02-15 :Copyright: The Linux Foundation and contributors :License: GPLv2+ :Version: 0.13.0 :Manual section: 5 SYNOPSIS -------- b4 {mbox,am,shazam,pr,diff,ty,kr,prep,send,trailers} [options] DESCRIPTION ----------- This is a helper utility to work with patches and pull requests made available via a public-inbox archive like lore.kernel.org. It is written to make it easier to participate in a patch-based workflows, like those used in the Linux kernel development. The name "b4" was chosen for ease of typing and because B-4 was the precursor to Lore and Data in the Star Trek universe. Full documentation is available on https://b4.docs.kernel.org/. SUBCOMMANDS ----------- Maintainer-oriented: * *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 Contributor-oriented: * *prep*: (EXPERIMENTAL) prepare your series for submission * *send*: (EXPERIMENTAL) send your series for review on distribution lists * *trailers*: (EXPERIMENTAL) retrieve and apply code-review trailers 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) --offline-mode Do not perform any network queries (default: False) --no-stdin Disable TTY detection for stdin (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] [-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 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) --stdin-pipe-sep STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string -C, --no-cache Do not use local cache --single-message Only retrieve the message matching the msgid and ignore the rest of the thread -o OUTDIR, --outdir OUTDIR Output into this directory (or use - to output mailbox contents to stdout) -c, --check-newer-revisions Check if newer patch revisions exist -n WANTNAME, --mbox-name WANTNAME 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 -r MBOX, --refetch MBOX Refetch all messages in specified mbox with their original headers   *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] [-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 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) --stdin-pipe-sep STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string -C, --no-cache Do not use local cache --single-message Only retrieve the message matching the msgid and ignore the rest of the thread -o OUTDIR, --outdir OUTDIR Output into this directory (or use - to output mailbox contents to stdout) -c, --check-newer-revisions Check if newer patch revisions exist -n WANTNAME, --mbox-name WANTNAME Filename to name the mbox destination -M, --save-as-maildir Save as maildir (avoids mbox format ambiguities) --single-message Only retrieve the message matching the msgid and ignore the rest of the thread -v WANTVER, --use-version WANTVER Get a specific version of the patch/series -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) -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) --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 shazam ~~~~~~~~~ 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: msgid Message ID to process, or pipe a raw message 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) --stdin-pipe-sep STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string -C, --no-cache Do not use local cache --single-message Only retrieve the message matching the msgid and ignore the rest of the thread -v WANTVER, --use-version WANTVER Get a specific version of the patch/series -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) --merge-base COMMIT (use with -H or -M) Force this base when merging *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: b4 pr [-h] [-g GITDIR] [-b BRANCH] [-c] [-e] [-o OUTMBOX] [msgid] positional arguments: msgid Message ID to process, or pipe a raw message optional arguments: -h, --help show this help message and exit -g GITDIR, --gitdir GITDIR Operate on this git tree instead of current dir -b BRANCH, --branch BRANCH Check out FETCH_HEAD into this branch after fetching -c, --check Check if pull request has already been applied -e, --explode Convert a pull request into an mbox full of patches -o OUTMBOX, --output-mbox OUTMBOX Save exploded messages into this mailbox (default: msgid.mbx) -f MAILFROM, --from-addr MAILFROM Use this From: in exploded messages (use with -e) *Example*: b4 pr 202003292120.2BDCB41@keescook b4 ty ~~~~~ usage: 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 -g GITDIR, --gitdir GITDIR Operate on this git tree instead of current dir -o OUTDIR, --outdir OUTDIR Write thanks files into this dir (default=.) -l, --list List pull requests and patch series you have retrieved -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 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 --pw-set-state PW_STATE Set this patchwork state instead of default (use with -a, -t or -d) .. 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] positional arguments: msgid Message ID to process, pipe a raw message, or use -m optional arguments: -h, --help show this help message and exit -g GITDIR, --gitdir GITDIR Operate on this git tree instead of current dir -p USEPROJECT, --use-project USEPROJECT Use a specific project instead of guessing (linux-mm, linux-hardening, etc) -C, --no-cache Do not use local cache -v WANTVERS [WANTVERS ...], --compare-versions WANTVERS [WANTVERS ...] Compare specific versions instead of latest and one before that, e.g. -v 3 5 -n, --no-diff Do not generate a diff, just show the command to do it -o OUTDIFF, --output-diff OUTDIFF Save diff into this file instead of outputting to stdout -c, --color Force color output even when writing to file -m AMBOX AMBOX, --compare-am-mboxes AMBOX AMBOX Compare two mbx files prepared with "b4 am" *Example*: b4 diff 20200526205322.23465-1-mic@digikod.net b4 kr ~~~~~ usage: b4 kr [-h] [-p USEPROJECT] [-m LOCALMBOX] [-C] [--show-keys] [msgid] positional arguments: msgid Message ID to process, or pipe a raw message optional arguments: -h, --help show this help message and exit -p USEPROJECT, --use-project USEPROJECT Use a specific project instead of guessing (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) --stdin-pipe-sep STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string -C, --no-cache Do not use local cache --show-keys Show all developer keys from the thread *Example*: b4 kr --show-keys 20210521184811.617875-1-konstantin@linuxfoundation.org b4 prep ~~~~~~~ usage: b4 prep [-h] [-c | -p OUTPUT_DIR | --edit-cover | --show-revision | --force-revision N | --compare-to vN | --manual-reroll COVER_MSGID | --set-prefixes PREFIX [PREFIX ...] | --show-info [PARAM] ] | [ --cleanup [BRANCHNAME] ] [-n NEW_SERIES_NAME] [-f FORK_POINT] [-F MSGID] [-e ENROLL_BASE] options: -h, --help show this help message and exit -c, --auto-to-cc Automatically populate cover letter trailers with To and Cc addresses -p OUTPUT_DIR, --format-patch OUTPUT_DIR Output prep-tracked commits as patches --edit-cover Edit the cover letter in your defined $EDITOR (or core.editor) --show-revision Show current series revision number --force-revision N Force revision to be this number instead --compare-to vN Display a range-diff to previously sent revision N --manual-reroll COVER_MSGID Mark current revision as sent and reroll (requires cover letter msgid) --set-prefixes PREFIX [PREFIX ...] Extra prefixes to add to [PATCH] (e.g.: RFC mydrv) --show-info [PARAM] Show series info in a format that can be passed to other tools --cleanup [BRANCHNAME] Archive and delete obsolete prep-managed branches Create new branch: Create a new branch for working on patch series -n NEW_SERIES_NAME, --new NEW_SERIES_NAME Create a new branch for working on a patch series -f FORK_POINT, --fork-point FORK_POINT When creating a new branch, use this fork point instead of HEAD -F MSGID, --from-thread MSGID When creating a new branch, use this thread Enroll existing branch: Enroll existing branch for prep work -e ENROLL_BASE, --enroll ENROLL_BASE Enroll current branch, using the passed tag, branch, or commit as fork base b4 send ~~~~~~~ usage: b4 send [-h] [-d | -o OUTPUT_DIR | --preview-to ADDR [ADDR ...] | --reflect] [--no-trailer-to-cc] [--to ADDR [ADDR ...]] [--cc ADDR [ADDR ...]] [--not-me-too] [--resend [vN]] [--no-sign] [--web-auth-new] [--web-auth-verify VERIFY_TOKEN] options: -h, --help show this help message and exit -d, --dry-run Do not send, just dump out raw smtp messages to the stdout --preview-to ADDR [ADDR ...] Send everything for a pre-review to specified addresses instead of actual recipients -o OUTPUT_DIR, --output-dir OUTPUT_DIR Do not send, write raw messages to this directory (forces --dry-run) --reflect Send everything to yourself instead of the actual recipients --no-trailer-to-cc Do not add any addresses found in the cover or patch trailers to To: or Cc: --to ADDR [ADDR ...] Addresses to add to the To: list --cc ADDR [ADDR ...] Addresses to add to the Cc: list --not-me-too Remove yourself from the To: or Cc: list --resend [vN] Resend a previously sent version of the series --no-sign Do not add the cryptographic attestation signature header Web submission: Authenticate with the web submission endpoint --web-auth-new Initiate a new web authentication request --web-auth-verify VERIFY_TOKEN Submit the token received via verification email b4 trailers ~~~~~~~~~~~ usage: b4 trailers [-h] [-u] [-S] [-F MSGID] [--since SINCE] options: -h, --help show this help message and exit -u, --update Update branch commits with latest received trailers -S, --sloppy-trailers Apply trailers without email address match checking -F MSGID, --trailers-from MSGID Look for trailers in the thread with this msgid instead of using the series change-id --since SINCE The --since option to use with -F when auto-matching patches (default=1.month) -m LOCALMBOX, --use-local-mbox LOCALMBOX Instead of grabbing a thread from lore, process this mbox file (or - for stdin) --stdin-pipe-sep STDIN_PIPE_SEP When accepting messages on stdin, split using this pipe separator string -C, --no-cache Do not use local cache CONFIGURATION ------------- B4 configuration is handled via git-config(1), so you can store it in either the toplevel $HOME/.gitconfig file, or in a per-repository .git/config file if your workflow changes per project. To see configuration options available, see online documentation at https://b4.docs.kernel.org/en/latest/config.html 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 ------- Please email tools@kernel.org with support requests, or browse the list archive at https://lore.kernel.org/tools. b4-0.13.0/misc/000077500000000000000000000000001456345453500130515ustar00rootroot00000000000000b4-0.13.0/misc/default.conf000066400000000000000000000060221456345453500153440ustar00rootroot00000000000000[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 # If we're processing a message that's not from one of our own domains, # we will fudge the From: header to be whatever is defined below in # sendemail.from. By default, we will tack on the original address of the # sender (slightly fudged) after the local part, using + (see # 'recipient_delimiter' in Postfix). # E.g.: # From: joe@example.org # becomes # From: devnull+joe.example.org@kernel.org # This is done so various automated tools that don't pay attention to # X-Original-From don't show these messages as all from the same account. from-recipient-delimiter = + # 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 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 [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 b4-0.13.0/misc/send-receive.py000066400000000000000000000617011456345453500160010ustar00rootroot00000000000000#!/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') cmsg.set_charset('utf-8') bdata = cmsg.as_bytes(policy=email.policy.SMTP) 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, reflect: bool = False) -> 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(rb'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I) diffstatre = re.compile(rb'^\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 = validfrom = None for umsg in umsgs: bdata = umsg.encode() try: identity, selector, auth_id = self.validate_message(conn, t_auth, bdata) except patatt.NoKeyError as ex: # noqa self.send_error(resp, message='No matching key, please complete web auth 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_bytes(bdata, policy=email.policy.SMTP) 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 subject = self.clean_header(msg.get('Subject', '')) if not 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(decode=True) 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 # Make sure that From: matches the validated identity. We allow + expansion, # such that foo+listname@example.com is allowed for foo@example.com allfroms = utils.getaddresses([str(x) for x in msg.get_all('from')]) # Allow only a single From: address if len(allfroms) > 1: self.send_error(resp, message='Message may only contain a single From: address.') return fromaddr = allfroms[0][1] if validfrom != fromaddr: ldparts = fromaddr.split('@') if len(ldparts) != 2: self.send_error(resp, message=f'Invalid address in From: {fromaddr}') return lparts = ldparts[0].split('+', maxsplit=1) toval = f'{lparts[0]}@{ldparts[1]}' if toval != identity: self.send_error(resp, message=f'From header invalid for identity {identity}: {fromaddr}') return # usually, all From: addresses will be the same, so use validfrom as a quick bypass if validfrom is None: validfrom = fromaddr # 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') and not reflect: repo = self._config['public-inbox'].get('repo') listid = self._config['public-inbox'].get('listid') if not os.path.isdir(repo): repo = None if reflect: logger.info('Reflecting %s messages back to %s', len(msgs), identity, selector) sentaction = 'Reflected' else: logger.info('Sending %s messages for %s/%s', len(msgs), identity, selector) sentaction = 'Sent' 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 = 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) # We can't just send this as-is due to DMARC policies. Therefore, we set # Reply-To and X-Original-From. fromaddr = frompair[1] origname = origpair[0] if not origname: origname = origpair[1] delim = self._config['main'].get('from-recipient-delimiter', '+') if delim and '@' in fromaddr: _flocal, _fdomain = fromaddr.split('@', maxsplit=1) _forig = origaddr.replace('@', '.') fromaddr = f'{_flocal}{delim}{_forig}@{_fdomain}' 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(decode=True) # Add a From: header (if there isn't already one), but only if it's a patch if diffre.search(body): # Parse it as a message and see if we get a From: header cmsg = email.message_from_bytes(body, policy=emlpolicy) if cmsg.get('From') is None: newbody = 'From: ' + self.clean_header(origfrom) + '\n' if cmsg.get('Subject'): newbody += 'Subject: ' + self.clean_header(cmsg.get('Subject')) + '\n' if cmsg.get('Date'): newbody += 'Date: ' + self.clean_header(cmsg.get('Date')) + '\n' newbody += '\n' + body.decode() msg.set_payload(newbody, charset='utf-8') # If we have non-ascii content in the new body, force CTE to 8bit if msg['Content-Transfer-Encoding'] == '7bit' and not all(ord(char) < 128 for char in newbody): msg.set_charset('utf-8') msg.replace_header('Content-Transfer-Encoding', '8bit') if bccaddrs: destaddrs.update(bccaddrs) if not self._config['main'].getboolean('dryrun'): bdata = msg.as_bytes(policy=email.policy.SMTP) if reflect: smtp.sendmail(fromaddr, [identity], bdata) else: smtp.sendmail(fromaddr, list(destaddrs), bdata) logger.info('%s: %s', sentaction, 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('%s %s messages for %s/%s', sentaction, len(msgs), identity, selector) self.send_success(resp, message=f'{sentaction} {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 if action == 'reflect': self.receive(jdata, resp, reflect=True) 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() b4-0.13.0/patatt/000077500000000000000000000000001456345453500134135ustar00rootroot00000000000000b4-0.13.0/pyproject.toml000066400000000000000000000002171456345453500150320ustar00rootroot00000000000000[tool.pytest.ini_options] filterwarnings = "ignore:.*(pyopenssl|invalid escape sequence).*:DeprecationWarning" norecursedirs = "tests/helpers" b4-0.13.0/requirements.txt000066400000000000000000000002331456345453500154000ustar00rootroot00000000000000requests>=2.24,<3.0 # These are optional, needed for attestation features dnspython>=2.1,<3.0 dkimpy>=1.0,<2.0 patatt>=0.6,<2.0 git-filter-repo>=2.30,<3.0 b4-0.13.0/setup.py000066400000000000000000000031001456345453500136220ustar00rootroot00000000000000#!/usr/bin/env python3 import os import re from setuptools import setup # Utility function to read the README file. # Used for the long_description. It's nice, because now 1) we have a top level # README file and 2) it's easier to type in the README file than to put a raw # string in below ... def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() def find_version(source): version_file = read(source) version_match = re.search(r"^__VERSION__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) raise RuntimeError("Unable to find version string.") NAME = 'b4' setup( version=find_version('b4/__init__.py'), url='https://git.kernel.org/pub/scm/utils/b4/b4.git/tree/README.rst', project_urls={ 'Community': 'https://lore.kernel.org/tools' }, name=NAME, description='A tool to work with public-inbox and patch archives', author='Konstantin Ryabitsev', author_email='mricon@kernel.org', packages=['b4'], license='GPLv2+', long_description=read('man/b4.5.rst'), long_description_content_type='text/x-rst', data_files=[('share/man/man5', ['man/b4.5'])], keywords=['git', 'public-inbox', 'lore.kernel.org', 'patches'], install_requires=[ 'requests>=2.24,<3.0', 'dnspython>=2.1,<3.0', 'dkimpy>=1.0,<2.0', 'patatt>=0.6,<2.0', 'git-filter-repo>=2.30,<3.0', ], python_requires='>=3.8', entry_points={ 'console_scripts': [ 'b4=b4.command:cmd' ], }, ) b4-0.13.0/shazam-merge-template.example000066400000000000000000000015041456345453500176640ustar00rootroot00000000000000# 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) b4-0.13.0/tests/000077500000000000000000000000001456345453500132605ustar00rootroot00000000000000b4-0.13.0/tests/__init__.py000066400000000000000000000000001456345453500153570ustar00rootroot00000000000000b4-0.13.0/tests/conftest.py000066400000000000000000000030621456345453500154600ustar00rootroot00000000000000import pytest # noqa import b4 import os import sys @pytest.fixture(scope="function", autouse=True) def settestdefaults(tmp_path): topdir = b4.git_get_toplevel() if topdir and topdir != os.getcwd(): os.chdir(topdir) b4.can_patatt = False b4.can_network = False b4.MAIN_CONFIG = dict(b4.DEFAULT_CONFIG) b4.USER_CONFIG = { 'name': 'Test Override', 'email': 'test-override@example.com', } os.environ['XDG_DATA_HOME'] = str(tmp_path) os.environ['XDG_CACHE_HOME'] = str(tmp_path) # This lets us avoid execvp-ing from inside b4 when testing sys._running_in_pytest = True @pytest.fixture(scope="function") def sampledir(request): return os.path.join(request.fspath.dirname, 'samples') @pytest.fixture(scope="function") def gitdir(request, tmp_path): sampledir = os.path.join(request.fspath.dirname, 'samples') # look for bundle file specific to the calling fspath bname = request.fspath.basename[5:-3] bfile = os.path.join(sampledir, f'{bname}-gitdir.bundle') if not os.path.exists(bfile): # Fall back to the default bfile = os.path.join(sampledir, 'gitdir.bundle') assert os.path.exists(bfile) dest = os.path.join(tmp_path, 'repo') args = ['clone', '--branch', 'master', bfile, dest] out, logstr = b4.git_run_command(None, args) assert out == 0 b4.git_set_config(dest, 'user.name', b4.USER_CONFIG['name']) b4.git_set_config(dest, 'user.email', b4.USER_CONFIG['email']) olddir = os.getcwd() os.chdir(dest) yield dest os.chdir(olddir) b4-0.13.0/tests/samples/000077500000000000000000000000001456345453500147245ustar00rootroot00000000000000b4-0.13.0/tests/samples/gitdir.bundle000066400000000000000000000035361456345453500174100ustar00rootroot00000000000000# v2 git bundle f435c12df7c0ecf20ab8937859e63cddffacabb4 refs/heads/master PACKxœ­ŽKj1D÷:E_`Lkd}&㬳K|–Ôm‹Ø’™Ñ„øö6|€¨ÕƒzU}f™RÒ☙´‰m˜¢“}­wf;e±¢®4sí& “N⌠dŒh²õlNH1 ‹¢µŸÚ ï­.j/>nK_ø^¿žt.uý‘¶ÖL½´ºióqÚ9çu0ÚÀ[D•ÚåRz窷œ`áÔj†G‡•:œÊ<ñïêF©Ïr¬œ‡&2ÄÛËß>¨;ž{(ã €Kxœ ȱ Â0@Q:OaèAgç|±K Z˜ …Ï9‹H¢`$Öˆ˜Í€Hõõþ¯úVm™D4™|ëXHLˆì)"&$“2±¥ÆSæ ¢Æ8ÉP4ÚÀq™Á&±Éuµï$¡DfªpjÀgs{\£sóç´]¢w€JºN_ûññ¼íË«,îå"Ó{³:ÿ¸+ˆžxœ­ŽMNÅ0ƒ÷9Å€¢$M›!~–ˆœ`’̼Fô%¨™ïöTBâˆeÙŸ-ÌzrÈf"Î#&ïmv&͈Q‡¼KÆXëRŽê7ªÌS6~ ('’ Sd³ØÚÌì &…»,mƒçV»`•Rá傱H§¸}ûuÖR÷/n{Í(¥Õë¶îÀÌóìM½†A;­Ujçs¡ªÇœáˆX8P\ÖC)õkëé¨Ë|.%-ð³ ¥ƒ,¥ß+õZN•òИ‡x¹ùÛ#õ 9j€ã ƒ8xœ É1Â0 @Ñ1¯ðÀŠ”ÇNF欜ÖV+!„J?RñÇȨtÛýšoÓ¯‹¤T‰y¬\„ŠJ>·ŽjNã0´Fk5‘,w/½5³?œM²~»Âª('³ ¸4¬êÆ;‘æ51/y’‚æ-8rì X1ìÑ+µxœ ÉÈ,V¢´ÌœTC=.Ï…ŒÄb…D…â̼t PNf^ªWTP859?/,¬à©˜’’š¢ÇLF¾xœ ÉÈ,V¢´ÌœT#=®(?Q!/µ\!'3/U!3. ^Sg»9xœMR[ŽÛ0 ü÷)xçèOn±@Ñ0ã°ÐÃEcß‘gócØ–8œ×¯Ú$“næ™bMµ‘i'ÎÒg µ˜„.ÝqÔM-hYI’ö…~’N»îÒÓ?·^çIœ.‰K„.}°)l¸×Y¨èå6¦¤U£­šKWË5¾öRb¬Qž„ÏVµÐŽw½xI)š†ñª½«ŽèUJ|Û–yœÌtU#'½;†ÖÆ»Fž©©9Fwê’·!›7•2ŸŸžzÓ bä­0W b4-0.13.0/tests/samples/gpg-good-invalid-notrust.txt000066400000000000000000000015171456345453500223340ustar00rootroot00000000000000[GNUPG:] NEWSIG [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] SIG_ID 5clUiMzlfE8KIyEu++mBk6I0Rnc 2021-06-09 1623274836 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] GOODSIG B6C41CE35664996C Konstantin Ryabitsev [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] TRUST_UNDEFINED 0 tofu b4-0.13.0/tests/samples/gpg-good-valid-notrust.txt000066400000000000000000000017271456345453500220100ustar00rootroot00000000000000[GNUPG:] NEWSIG [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] SIG_ID 5clUiMzlfE8KIyEu++mBk6I0Rnc 2021-06-09 1623274836 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] GOODSIG B6C41CE35664996C Konstantin Ryabitsev [GNUPG:] VALIDSIG 76BE5DB25271E1481E678C35B6C41CE35664996C 2021-06-09 1623274836 0 4 0 22 8 01 DE0E66E32F1FDD0902666B96E63EDCA9329DD07E [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] TRUST_UNDEFINED 0 tofu b4-0.13.0/tests/samples/gpg-good-valid-trusted.txt000066400000000000000000000017261456345453500217630ustar00rootroot00000000000000[GNUPG:] NEWSIG [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] SIG_ID 5clUiMzlfE8KIyEu++mBk6I0Rnc 2021-06-09 1623274836 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] GOODSIG B6C41CE35664996C Konstantin Ryabitsev [GNUPG:] VALIDSIG 76BE5DB25271E1481E678C35B6C41CE35664996C 2021-06-09 1623274836 0 4 0 22 8 01 DE0E66E32F1FDD0902666B96E63EDCA9329DD07E [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] TRUST_ULTIMATE 0 tofu b4-0.13.0/tests/samples/gpg-no-pubkey.txt000066400000000000000000000002231456345453500201460ustar00rootroot00000000000000[GNUPG:] NEWSIG [GNUPG:] ERRSIG B6C41CE35664996C 22 8 01 1623274836 9 76BE5DB25271E1481E678C35B6C41CE35664996C [GNUPG:] NO_PUBKEY B6C41CE35664996C b4-0.13.0/tests/samples/save-7bit-clean.txt000066400000000000000000000006031456345453500203450ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: Test =?utf-8?q?S=C3=BBbject?= From: =?utf-8?b?VW5pY8O0ZMOpIE7DoG3DqA==?= <8-bit-header-unicodename@example.org> Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit This is à Méssagè with 8bit content éverywhère. b4-0.13.0/tests/samples/save-8bit-clean.txt000066400000000000000000000005341456345453500203510ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: Test Sûbject From: Unicôdé Nàmè <8-bit-header-unicodename@example.org> Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit This is à Méssagè with 8bit content éverywhère. b4-0.13.0/tests/samples/sevenbitify-1.verify000066400000000000000000000007721456345453500206450ustar00rootroot00000000000000From: Unicode =?utf-8?q?N=C3=A2me?= To: Ascii Name , =?utf-8?q?Unic=C3=B4de?= Firstname , Unicode =?utf-8?q?L=C3=A2stname?= Subject: Subject with =?utf-8?q?unic=C3=B4de_that_is_randomly_intersp=C3=A9r?= =?utf-8?q?sed_thr=C3=B4ughout?= the wrapped subject MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Unicôde Côntent in the body. b4-0.13.0/tests/samples/shazam-git1-just-series-defaults.verify000066400000000000000000000027611456345453500243650ustar00rootroot00000000000000konstantin@linuxfoundation.org test-override@example.com Minor typo changes imitation Life imitatus artem. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org test-override@example.com Add some paragraphs to lipsum Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org test-override@example.com Add more lines to file 1 This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org test-override@example.com Remove line 2 from file2 Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev --- b4-0.13.0/tests/samples/shazam-git1-just-series-merged.verify000066400000000000000000000035051456345453500240160ustar00rootroot00000000000000test-override@example.com test-override@example.com Merge patch series "This is a cover for test series 1" Konstantin Ryabitsev says: Test patches for pytest. Link: https://lore.kernel.org/r/20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org Signed-off-by: Test Override --- konstantin@linuxfoundation.org test-override@example.com Minor typo changes imitation Life imitatus artem. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org test-override@example.com Add some paragraphs to lipsum Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org test-override@example.com Add more lines to file 1 This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org test-override@example.com Remove line 2 from file2 Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev --- b4-0.13.0/tests/samples/shazam-git1-just-series.mbox000066400000000000000000000260161456345453500222200ustar00rootroot00000000000000From mboxrd@git Thu Jan 1 00:00:00 1970 Subject: [PATCH 0/4] This is a cover for test series 1 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit X-b4-tracking: H4sIACEfWGMC/w3KwQqAIAwA0F+RnRvMhRD9jdpIQQycdRH/PY8P3gCVlkXhNAOafFnzUxfsZiAmX2 /BfC0DE7MldthFu0U5HFNkJvI7rBu8Cobma0xr17eUOX9DGAR1XAAAAA== From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:41 -0400 Message-Id: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Cc: Konstantin Ryabitsev X-Mailer: b4 0.11.0-dev-58177 X-Developer-Signature: v=1; a=openpgp-sha256; l=584; i=konstantin@linuxfoundation.org; h=from:subject:message-id; bh=FEe26yiIiUIip3D/fCxDx+1tqTz2+Wr5V7gE21ONPQc=; b=owGbwMvMwCW27YjM47CUmTmMp9WSGJIj5BWvFTnFdtQ2fDNLtBbinLnr9pEV5ysuBP6c0b6wIt4w Ii2to5SFQYyLQVZMkaVsX+ymoMKHHnLpPaYwc1iZQIYwcHEKwESKjjH8z1j8xqZs6ryL8Y/nv9g8r7 NKSuaa5J/FxVN+PdqrEnJZ4hwjw+Vp/84HtpnxX7uXp9Sw9ojKHE/OSV98X32+1f70199LV/kB X-Developer-Key: i=konstantin@linuxfoundation.org; a=openpgp; fpr=DE0E66E32F1FDD0902666B96E63EDCA9329DD07E Status: O Content-Length: 562 Lines: 22 Test patches for pytest. Signed-off-by: Konstantin Ryabitsev --- Konstantin Ryabitsev (4): Remove line 2 from file2 Add more lines to file 1 Add some paragraphs to lipsum Minor typo changes imitation file1.txt | 12 ++++++++++++ file2.txt | 1 - lipsum.txt | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) --- base-commit: f435c12df7c0ecf20ab8937859e63cddffacabb4 change-id: 20221025-test1-e8520c2200a3 Best regards, -- Konstantin Ryabitsev From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:42 -0400 Subject: [PATCH 1/4] Remove line 2 from file2 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-1-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Cc: Konstantin Ryabitsev X-Mailer: b4 0.11.0-dev-58177 X-Developer-Signature: v=1; a=openpgp-sha256; l=1092; i=konstantin@linuxfoundation.org; h=from:subject:message-id; bh=zfPu6UbRvPUgMHVFSLLRI0lElRyBwE9n+14TovJnuYg=; b=owGbwMvMwCW27YjM47CUmTmMp9WSGJIj5BUrv6hFNy2SunEuNPtW8O0lHOx3rky/w/px8sWEFRPd Di861FHKwiDGxSArpshSti92U1DhQw+59B5TmDmsTCBDGLg4BWAilesZ/nA+cJat4H7O91R4fUjs/3 NZCZ9Fptk0Cz/InHjr5J+4vPcM/72+5v7kXLcv7N4HjVuz8sQre788LX3O6ZLK+/XGm8/TTvEBAA== X-Developer-Key: i=konstantin@linuxfoundation.org; a=openpgp; fpr=DE0E66E32F1FDD0902666B96E63EDCA9329DD07E Status: O Content-Length: 1062 Lines: 30 Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev --- file2.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/file2.txt b/file2.txt index d5a0124..3eac351 100644 --- a/file2.txt +++ b/file2.txt @@ -1,2 +1 @@ This is file 2. -This is a new line in file 2. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:43 -0400 Subject: [PATCH 2/4] Add more lines to file 1 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-2-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Cc: Konstantin Ryabitsev X-Mailer: b4 0.11.0-dev-58177 X-Developer-Signature: v=1; a=openpgp-sha256; l=1108; i=konstantin@linuxfoundation.org; h=from:subject:message-id; bh=Q88W/T/bAIWZ7OMIFSAFDoA0YpRrh5uPCp8Nl9ccs0Q=; b=owGbwMvMwCW27YjM47CUmTmMp9WSGJIj5BUbNQ9+eSd9Vm7PLMMmnhu2N98dnGNtf8IuZnbo44cv Tj6K6ShlYRDjYpAVU2Qp2xe7KajwoYdceo8pzBxWJpAhDFycAjARzzxGhk15ofHHPG8LV4UG/VEU+y Y9eZ7K0Zf/DYPqa7Y/M5gxeQcjw1lB1wUTKx9Lb69Y8t1xK1PvzfrPP/IW+f51yd89W1diHz8A X-Developer-Key: i=konstantin@linuxfoundation.org; a=openpgp; fpr=DE0E66E32F1FDD0902666B96E63EDCA9329DD07E Status: O Content-Length: 1077 Lines: 31 This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev --- file1.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/file1.txt b/file1.txt index b352682..936a0c8 100644 --- a/file1.txt +++ b/file1.txt @@ -1,3 +1,15 @@ This is file 1. It has a single line. This is a second line I added. +This is a third line that belongs here. + +As well as the following words of wisdom: + +Duis rutrum eu urna eu viverra. Curabitur vehicula ut nisl id accumsan. +Praesent condimentum mattis rutrum. Maecenas tincidunt ipsum nec hendrerit +pulvinar. Suspendisse sed commodo nisl, vitae tincidunt libero. Donec lacus +ante, maximus eu nulla in, lacinia aliquam sem. Fusce efficitur nisl elit, et +sodales nisi pellentesque in. Nulla eros sem, sagittis sit amet nisi non, +venenatis accumsan augue. Sed ipsum ligula, sodales nec pharetra eget, congue +id mi. Duis et accumsan lacus, in sollicitudin metus. In eu velit ut tortor +finibus sodales. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:44 -0400 Subject: [PATCH 3/4] Add some paragraphs to lipsum MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-3-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Cc: Konstantin Ryabitsev X-Mailer: b4 0.11.0-dev-58177 X-Developer-Signature: v=1; a=openpgp-sha256; l=1828; i=konstantin@linuxfoundation.org; h=from:subject:message-id; bh=J5kDTc3PYEDcsLO5Vf2zTCEk4ebVong3oEoRm681pBs=; b=owGbwMvMwCW27YjM47CUmTmMp9WSGJIj5BV33f317vx7y7zj18pYfp/4/Xjmxknv8+82/jYTumsv b3A2t6OUhUGMi0FWTJGlbF/spqDChx5y6T2mMHNYmUCGMHBxCsBErt1k+KcovejsJqaMax77TpTOeL 3UTePTAn/m22V67OE8zWppbcsYGc4FX1x0/Jss37QHtq7ilVv6X9qq7f2S+eiA560rMubrjzEAAA== X-Developer-Key: i=konstantin@linuxfoundation.org; a=openpgp; fpr=DE0E66E32F1FDD0902666B96E63EDCA9329DD07E Status: O Content-Length: 1792 Lines: 36 Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev --- lipsum.txt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lipsum.txt b/lipsum.txt index d8f8dc5..750dabb 100644 --- a/lipsum.txt +++ b/lipsum.txt @@ -4,9 +4,19 @@ est. Proin vestibulum enim et sagittis eleifend. Maecenas mattis, felis ac aliquet gravida, risus erat tempor sapien, tempor ultricies urna magna non urna. Nam luctus lorem a enim pulvinar, eget viverra dui viverra. Praesent ut libero a lectus posuere tempus. Interdum et malesuada fames ac ante ipsum -primis in faucibus. Phasellus cursus lectus sed metus interdum aliquet. Ut non +primis in faucibus. Phasellius cursus lectus sed metus interdum aliquet. Ut non erat consectetur, pretium quam at, maximus nisl. Donec tempus facilisis ex sit amet condimentum. Suspendisse vitae diam sem. Donec ligula tellus, scelerisque -efficitur mauris non, euismod auctor urna. Morbi ac hendrerit erat. Phasellus +efficitur mauris non, euismodae auctor urna. Morbi ac hendrerit erat. Phasellus at mauris id dui malesuada porta ut et justo. Integer convallis quis ligula vitae sollicitudin. Nulla sit amet tincidunt nulla, eget vestibulum massa. + +Interdum et malesuada fames ac ante ipsum primis in faucibus. Curabitur nec dui +ut lacus vehicula aliquet. Curabitur ac tincidunt risus. Etiam in eros massa. +Curabitur scelerisque orci non justo sollicitudin bibendum. Sed quis risus +imperdiet, tincidunt quam eget, consectetur eros. Maecenas sollicitudin augue +quis odio fermentum, eget elementum ipsum maximus. Etiam quam odio, tempus nec +posuere at, facilisis nec turpis. + +Curabitur porttitor sit amet odio ac rutrum. +Integer bibendum metus eu bibendum porttitor. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:45 -0400 Subject: [PATCH 4/4] Minor typo changes imitation MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-4-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Cc: Konstantin Ryabitsev X-Mailer: b4 0.11.0-dev-58177 X-Developer-Signature: v=1; a=openpgp-sha256; l=1064; i=konstantin@linuxfoundation.org; h=from:subject:message-id; bh=EQePByhrPcGZfFXT+l7nTJOs3RuWSKCRQfZ0rr2d05U=; b=owGbwMvMwCW27YjM47CUmTmMp9WSGJIj5BW3JG1kWJRodLvAeLpPlUnz4Y/KQsu7Xr2rD52891F+ 1U6rjlIWBjEuBlkxRZayfbGbggofesil95jCzGFlAhnCwMUpABPhLGFkmCfQPKmSNfF/3IGoeT9W1S /J+R4s53NX8nn+/LYMk8yaqYwML27xvzBWKfxmL1T+IS1UmS/m8+/Tjm8mLok7cLvO79IGRgA= X-Developer-Key: i=konstantin@linuxfoundation.org; a=openpgp; fpr=DE0E66E32F1FDD0902666B96E63EDCA9329DD07E Status: O Content-Length: 1037 Lines: 27 Life imitatus artem. Signed-off-by: Konstantin Ryabitsev --- lipsum.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lipsum.txt b/lipsum.txt index 750dabb..d304f7f 100644 --- a/lipsum.txt +++ b/lipsum.txt @@ -13,10 +13,10 @@ vitae sollicitudin. Nulla sit amet tincidunt nulla, eget vestibulum massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Curabitur nec dui ut lacus vehicula aliquet. Curabitur ac tincidunt risus. Etiam in eros massa. -Curabitur scelerisque orci non justo sollicitudin bibendum. Sed quis risus +Curabitur scelerisque orci non justo sollicitudin vivendum. Sed quis risus imperdiet, tincidunt quam eget, consectetur eros. Maecenas sollicitudin augue quis odio fermentum, eget elementum ipsum maximus. Etiam quam odio, tempus nec posuere at, facilisis nec turpis. Curabitur porttitor sit amet odio ac rutrum. -Integer bibendum metus eu bibendum porttitor. +Floatus bibendum metus eu bibendum porttitor. -- b4 0.11.0-dev-58177 b4-0.13.0/tests/samples/trailers-followup-bare-address-ref-defaults.txt000066400000000000000000000015101456345453500260650ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 7bit Follow-up trailer collating test. Fixes: abcdef01234567890 Cc: bare-address@example.org Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 (corporate) Tested-by: Followup Reviewer2 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 b4-0.13.0/tests/samples/trailers-followup-bare-address.mbox000066400000000000000000000040141456345453500236360ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH] Simple test To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: Follow-up trailer collating test. Fixes: abcdef01234567890 Cc: bare-address@example.org Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 (corporate) -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Followup Reviewer2 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: >> This is a simple trailer parsing test. > > Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Mismatched Reviewer Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Mismatched Reviewer1 -- My sig b4-0.13.0/tests/samples/trailers-followup-custody-ref-ordered.txt000066400000000000000000000021211456345453500250370ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Signed-off-by: Original Submitter Cc: Dev Eloper1 Cc: Dev Eloper2 Cc: Some List Fixes: abcdef01234567890 Link: https://lore.kernel.org/some@msgid.here # bug discussion Suggested-by: Friendly Suggester Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-custody-ref-unordered.txt000066400000000000000000000021211456345453500254020ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Signed-off-by: Original Submitter Suggested-by: Friendly Suggester Fixes: abcdef01234567890 Link: https://lore.kernel.org/some@msgid.here # bug discussion Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 Cc: Dev Eloper1 Cc: Dev Eloper2 Cc: Some List Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-custody-ref-with-ignored.txt000066400000000000000000000020201456345453500260110ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Signed-off-by: Original Submitter Suggested-by: Friendly Suggester Fixes: abcdef01234567890 Link: https://lore.kernel.org/some@msgid.here # bug discussion Tested-by: Followup Reviewer2 Cc: Dev Eloper1 Cc: Dev Eloper2 Cc: Some List Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-custody.mbox000066400000000000000000000033731456345453500230030ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH] Simple test To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: Follow-up trailer collating test. Link: https://msgid.link/some@msgid.here Reviewed-by: Original Reviewer Signed-off-by: Original Submitter Suggested-by: Friendly Suggester Fixes: abcdef01234567890 Link: https://lore.kernel.org/some@msgid.here # bug discussion Signed-off-by: Test Override --- 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 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Followup Reviewer2 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: >> This is a simple trailer parsing test. > > Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 -- My sig b4-0.13.0/tests/samples/trailers-followup-name-parens-ref-defaults.txt000066400000000000000000000014531456345453500257450ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 (corporate) Tested-by: Followup Reviewer2 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 b4-0.13.0/tests/samples/trailers-followup-name-parens.mbox000066400000000000000000000037571456345453500235250ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH] Simple test To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: Follow-up trailer collating test. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 (corporate) -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Followup Reviewer2 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: >> This is a simple trailer parsing test. > > Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Mismatched Reviewer Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Mismatched Reviewer1 -- My sig b4-0.13.0/tests/samples/trailers-followup-non-git-patch-ref-defaults.txt000066400000000000000000000012331456345453500262030ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 --- a/b4/junk.py +++ b/b4/junk.py @@@ -1,1 +1,1 @@ def junk(): -junk1 +junk2 -- 2.wong.fu b4-0.13.0/tests/samples/trailers-followup-non-git-patch.mbox000066400000000000000000000020151456345453500237510ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH] Simple test To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: Follow-up trailer collating test. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 -- My sig b4-0.13.0/tests/samples/trailers-followup-nore-ref-defaults.txt000066400000000000000000000013401456345453500244750ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 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 b4-0.13.0/tests/samples/trailers-followup-nore.mbox000066400000000000000000000021221456345453500222430ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH] Simple test To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: Follow-up trailer collating test. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 -- My sig b4-0.13.0/tests/samples/trailers-followup-partial-reroll-ref-defaults.txt000066400000000000000000000031771456345453500264750ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH v3 1/2] Simple test 1 From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Coverletter Reviewer1 Signed-off-by: Test Override 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 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Coverletter Reviewer1 Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-partial-reroll.mbox000066400000000000000000000073471456345453500242470ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH v2 0/2] Simple cover To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Subject: [PATCH v2 1/2] Simple test 1 To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: Follow-up trailer collating test patch 1. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: [PATCH v2 2/2] Simple test 2 To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: Follow-up trailer collating test patch 2. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: Re: [PATCH v2 2/2] Simple test 2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Followup Reviewer1 Subject: Re: [PATCH v2 0/2] Simple cover Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Coverletter Reviewer1 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH v3 2/2] Simple test 2 To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: Follow-up trailer collating test patch 2. Partial reroll test. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-addlink.txt000066400000000000000000000016211456345453500246140ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 Link: https://lore.kernel.org/r/orig-message@example.com Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-addmsgid.txt000066400000000000000000000015771456345453500247740ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 Message-Id: Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-addmysob.txt000066400000000000000000000015301456345453500250070ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-copyccs.txt000066400000000000000000000017251456345453500246560ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 Cc: Dev Eloper1 Cc: Dev Eloper2 Cc: Some List Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-defaults.txt000066400000000000000000000014371456345453500250220ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-noadd.txt000066400000000000000000000012431456345453500242730ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-ordered.txt000066400000000000000000000017251456345453500246370ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Cc: Dev Eloper1 Cc: Dev Eloper2 Cc: Some List Tested-by: Followup Reviewer2 Reviewed-by: Followup Reviewer1 Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-single-ref-sloppy.txt000066400000000000000000000016351456345453500245410ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 Reviewed-by: Mismatched Reviewer1 Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-single.mbox000066400000000000000000000037431456345453500225730ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH] Simple test To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: Follow-up trailer collating test. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Followup Reviewer2 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: >> This is a simple trailer parsing test. > > Reviewed-by: Followup Reviewer1 Tested-by: Followup Reviewer2 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Mismatched Reviewer Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Mismatched Reviewer1 -- My sig b4-0.13.0/tests/samples/trailers-followup-with-cover-ref-addlink.txt000066400000000000000000000034471456345453500254320ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH v2 1/2] Simple test 1 From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Coverletter Reviewer1 Link: https://lore.kernel.org/r/patch-1-message@example.com Signed-off-by: Test Override 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 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Reviewed-by: Coverletter Reviewer1 Link: https://lore.kernel.org/r/patch-2-message@example.com Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-with-cover-ref-defaults.txt000066400000000000000000000032571456345453500256320ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH v2 1/2] Simple test 1 From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Coverletter Reviewer1 Signed-off-by: Test Override 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 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 Reviewed-by: Coverletter Reviewer1 Signed-off-by: Test Override 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 b4-0.13.0/tests/samples/trailers-followup-with-cover.mbox000066400000000000000000000056011456345453500233740ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH v2 0/2] Simple cover To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Subject: [PATCH v2 1/2] Simple test 1 To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: Follow-up trailer collating test patch 1. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: [PATCH v2 2/2] Simple test 2 To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: Follow-up trailer collating test patch 2. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- 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 Subject: Re: [PATCH v2 2/2] Simple test 2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 -- My sig From foo@z Thu Jan 1 00:00:00 1970 From: Followup Reviewer1 Subject: Re: [PATCH v2 0/2] Simple cover Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Coverletter Reviewer1 -- My sig b4-0.13.0/tests/samples/trailers-followup-with-diffstat-ref-defaults.txt000066400000000000000000000014461456345453500263160ustar00rootroot00000000000000From git@z Thu Jan 1 00:00:00 1970 Subject: [PATCH] Simple test From: Test Test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: 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 Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter Reviewed-by: Followup Reviewer1 --- b4/junk.py | 1 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b4-0.13.0/tests/samples/trailers-followup-with-diffstat.mbox000066400000000000000000000022301456345453500240550ustar00rootroot00000000000000From foo@z Thu Jan 1 00:00:00 1970 From: Test Test Subject: [PATCH] Simple test To: Some List Cc: Dev Eloper1 , Dev Eloper2 Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: Follow-up trailer collating test. Fixes: abcdef01234567890 Reviewed-by: Original Reviewer Link: https://msgid.link/some@msgid.here Signed-off-by: Original Submitter --- b4/junk.py | 1 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 Subject: Re: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 Message-Id: In-Reply-To: References: > This is a simple trailer parsing test. Reviewed-by: Followup Reviewer1 -- My sig b4-0.13.0/tests/samples/trailers-test-extinfo.txt000066400000000000000000000011711456345453500217410ustar00rootroot00000000000000From: Test Test Subject: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 This is a simple trailer parsing test. Reviewed-by: Bogus Bupkes [for the parts that are bogus] Fixes: abcdef01234567890 Tested-by: Some Person [this person visually indented theirs] Link: https://msgid.link/some@msgid.here # initial submission Signed-off-by: Wrapped Persontrailer --- 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 b4-0.13.0/tests/samples/trailers-test-simple.txt000066400000000000000000000006541456345453500215630ustar00rootroot00000000000000From: Test Test Subject: [PATCH] Simple test Date: Tue, 30 Aug 2022 11:19:07 -0400 This is a simple trailer parsing test. Reviewed-by: Bogus Bupkes 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 b4-0.13.0/tests/samples/trailers-thread-with-cover-followup.mbox000066400000000000000000000220231456345453500246360ustar00rootroot00000000000000From mboxrd@git Thu Jan 1 00:00:00 1970 Subject: [PATCH 0/4] This is a cover for test series 1 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:41 -0400 Message-Id: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Test patches for pytest. Signed-off-by: Konstantin Ryabitsev --- Konstantin Ryabitsev (4): Remove line 2 from file2 Add more lines to file 1 Add some paragraphs to lipsum Minor typo changes imitation file1.txt | 12 ++++++++++++ file2.txt | 1 - lipsum.txt | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) --- base-commit: f435c12df7c0ecf20ab8937859e63cddffacabb4 change-id: 20221025-test1-e8520c2200a3 Best regards, -- Konstantin Ryabitsev From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:42 -0400 Subject: [PATCH 1/4] Remove line 2 from file2 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-1-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev --- file2.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/file2.txt b/file2.txt index d5a0124..3eac351 100644 --- a/file2.txt +++ b/file2.txt @@ -1,2 +1 @@ This is file 2. -This is a new line in file 2. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:43 -0400 Subject: [PATCH 2/4] Add more lines to file 1 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-2-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev --- file1.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/file1.txt b/file1.txt index b352682..936a0c8 100644 --- a/file1.txt +++ b/file1.txt @@ -1,3 +1,15 @@ This is file 1. It has a single line. This is a second line I added. +This is a third line that belongs here. + +As well as the following words of wisdom: + +Duis rutrum eu urna eu viverra. Curabitur vehicula ut nisl id accumsan. +Praesent condimentum mattis rutrum. Maecenas tincidunt ipsum nec hendrerit +pulvinar. Suspendisse sed commodo nisl, vitae tincidunt libero. Donec lacus +ante, maximus eu nulla in, lacinia aliquam sem. Fusce efficitur nisl elit, et +sodales nisi pellentesque in. Nulla eros sem, sagittis sit amet nisi non, +venenatis accumsan augue. Sed ipsum ligula, sodales nec pharetra eget, congue +id mi. Duis et accumsan lacus, in sollicitudin metus. In eu velit ut tortor +finibus sodales. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:44 -0400 Subject: [PATCH 3/4] Add some paragraphs to lipsum MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-3-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev --- lipsum.txt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lipsum.txt b/lipsum.txt index d8f8dc5..750dabb 100644 --- a/lipsum.txt +++ b/lipsum.txt @@ -4,9 +4,19 @@ est. Proin vestibulum enim et sagittis eleifend. Maecenas mattis, felis ac aliquet gravida, risus erat tempor sapien, tempor ultricies urna magna non urna. Nam luctus lorem a enim pulvinar, eget viverra dui viverra. Praesent ut libero a lectus posuere tempus. Interdum et malesuada fames ac ante ipsum -primis in faucibus. Phasellus cursus lectus sed metus interdum aliquet. Ut non +primis in faucibus. Phasellius cursus lectus sed metus interdum aliquet. Ut non erat consectetur, pretium quam at, maximus nisl. Donec tempus facilisis ex sit amet condimentum. Suspendisse vitae diam sem. Donec ligula tellus, scelerisque -efficitur mauris non, euismod auctor urna. Morbi ac hendrerit erat. Phasellus +efficitur mauris non, euismodae auctor urna. Morbi ac hendrerit erat. Phasellus at mauris id dui malesuada porta ut et justo. Integer convallis quis ligula vitae sollicitudin. Nulla sit amet tincidunt nulla, eget vestibulum massa. + +Interdum et malesuada fames ac ante ipsum primis in faucibus. Curabitur nec dui +ut lacus vehicula aliquet. Curabitur ac tincidunt risus. Etiam in eros massa. +Curabitur scelerisque orci non justo sollicitudin bibendum. Sed quis risus +imperdiet, tincidunt quam eget, consectetur eros. Maecenas sollicitudin augue +quis odio fermentum, eget elementum ipsum maximus. Etiam quam odio, tempus nec +posuere at, facilisis nec turpis. + +Curabitur porttitor sit amet odio ac rutrum. +Integer bibendum metus eu bibendum porttitor. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:45 -0400 Subject: [PATCH 4/4] Minor typo changes imitation MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-4-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Life imitatus artem. Signed-off-by: Konstantin Ryabitsev --- lipsum.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lipsum.txt b/lipsum.txt index 750dabb..d304f7f 100644 --- a/lipsum.txt +++ b/lipsum.txt @@ -13,10 +13,10 @@ vitae sollicitudin. Nulla sit amet tincidunt nulla, eget vestibulum massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Curabitur nec dui ut lacus vehicula aliquet. Curabitur ac tincidunt risus. Etiam in eros massa. -Curabitur scelerisque orci non justo sollicitudin bibendum. Sed quis risus +Curabitur scelerisque orci non justo sollicitudin vivendum. Sed quis risus imperdiet, tincidunt quam eget, consectetur eros. Maecenas sollicitudin augue quis odio fermentum, eget elementum ipsum maximus. Etiam quam odio, tempus nec posuere at, facilisis nec turpis. Curabitur porttitor sit amet odio ac rutrum. -Integer bibendum metus eu bibendum porttitor. +Floatus bibendum metus eu bibendum porttitor. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Follow Upper Date: Tue, 25 Oct 2022 13:38:45 -0400 Subject: Re: [PATCH 4/4] Minor typo changes imitation MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: In-Reply-To: <20221025-test1-v1-4-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> <20221025-test1-v1-4-e4f28f57990c@linuxfoundation.org> To: list@example.org > Life imitatus artem. > > Signed-off-by: Konstantin Ryabitsev Tested-by: Follow Upper Link: https://example.org Closes: https://example.org/bug/1234 -- Follow Upper From mboxrd@git Thu Jan 1 00:00:00 1970 From: Cover Upper Date: Tue, 25 Oct 2022 13:38:45 -0400 Subject: Re: [PATCH 0/4] This is a cover for test series 1 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org > Test patches for pytest. Reviewed-by: Cover Upper -- Cover Upper b4-0.13.0/tests/samples/trailers-thread-with-cover-followup.verify000066400000000000000000000036531456345453500252050ustar00rootroot00000000000000konstantin@linuxfoundation.org Minor typo changes imitation Life imitatus artem. Signed-off-by: Konstantin Ryabitsev Tested-by: Follow Upper Link: https://example.org Closes: https://example.org/bug/1234 Reviewed-by: Cover Upper Signed-off-by: Test Override --- konstantin@linuxfoundation.org Add some paragraphs to lipsum Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev Reviewed-by: Cover Upper Signed-off-by: Test Override --- konstantin@linuxfoundation.org Add more lines to file 1 This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev Reviewed-by: Cover Upper Signed-off-by: Test Override --- konstantin@linuxfoundation.org Remove line 2 from file2 Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev Reviewed-by: Cover Upper Signed-off-by: Test Override --- b4-0.13.0/tests/samples/trailers-thread-with-followups-and-tripledash.verify000066400000000000000000000027371456345453500271530ustar00rootroot00000000000000konstantin@linuxfoundation.org Minor typo changes imitation Life imitatus artem. Signed-off-by: Konstantin Ryabitsev Reviewed-by: Follow Upper --- This commit has extra stuff. --- konstantin@linuxfoundation.org Add some paragraphs to lipsum Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org Add more lines to file 1 This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev --- konstantin@linuxfoundation.org Remove line 2 from file2 Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev --- b4-0.13.0/tests/samples/trailers-thread-with-followups.mbox000066400000000000000000000206401456345453500237100ustar00rootroot00000000000000From mboxrd@git Thu Jan 1 00:00:00 1970 Subject: [PATCH 0/4] This is a cover for test series 1 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:41 -0400 Message-Id: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Test patches for pytest. Signed-off-by: Konstantin Ryabitsev --- Konstantin Ryabitsev (4): Remove line 2 from file2 Add more lines to file 1 Add some paragraphs to lipsum Minor typo changes imitation file1.txt | 12 ++++++++++++ file2.txt | 1 - lipsum.txt | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) --- base-commit: f435c12df7c0ecf20ab8937859e63cddffacabb4 change-id: 20221025-test1-e8520c2200a3 Best regards, -- Konstantin Ryabitsev From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:42 -0400 Subject: [PATCH 1/4] Remove line 2 from file2 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-1-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev --- file2.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/file2.txt b/file2.txt index d5a0124..3eac351 100644 --- a/file2.txt +++ b/file2.txt @@ -1,2 +1 @@ This is file 2. -This is a new line in file 2. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:43 -0400 Subject: [PATCH 2/4] Add more lines to file 1 MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-2-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev --- file1.txt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/file1.txt b/file1.txt index b352682..936a0c8 100644 --- a/file1.txt +++ b/file1.txt @@ -1,3 +1,15 @@ This is file 1. It has a single line. This is a second line I added. +This is a third line that belongs here. + +As well as the following words of wisdom: + +Duis rutrum eu urna eu viverra. Curabitur vehicula ut nisl id accumsan. +Praesent condimentum mattis rutrum. Maecenas tincidunt ipsum nec hendrerit +pulvinar. Suspendisse sed commodo nisl, vitae tincidunt libero. Donec lacus +ante, maximus eu nulla in, lacinia aliquam sem. Fusce efficitur nisl elit, et +sodales nisi pellentesque in. Nulla eros sem, sagittis sit amet nisi non, +venenatis accumsan augue. Sed ipsum ligula, sodales nec pharetra eget, congue +id mi. Duis et accumsan lacus, in sollicitudin metus. In eu velit ut tortor +finibus sodales. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:44 -0400 Subject: [PATCH 3/4] Add some paragraphs to lipsum MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-3-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev --- lipsum.txt | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/lipsum.txt b/lipsum.txt index d8f8dc5..750dabb 100644 --- a/lipsum.txt +++ b/lipsum.txt @@ -4,9 +4,19 @@ est. Proin vestibulum enim et sagittis eleifend. Maecenas mattis, felis ac aliquet gravida, risus erat tempor sapien, tempor ultricies urna magna non urna. Nam luctus lorem a enim pulvinar, eget viverra dui viverra. Praesent ut libero a lectus posuere tempus. Interdum et malesuada fames ac ante ipsum -primis in faucibus. Phasellus cursus lectus sed metus interdum aliquet. Ut non +primis in faucibus. Phasellius cursus lectus sed metus interdum aliquet. Ut non erat consectetur, pretium quam at, maximus nisl. Donec tempus facilisis ex sit amet condimentum. Suspendisse vitae diam sem. Donec ligula tellus, scelerisque -efficitur mauris non, euismod auctor urna. Morbi ac hendrerit erat. Phasellus +efficitur mauris non, euismodae auctor urna. Morbi ac hendrerit erat. Phasellus at mauris id dui malesuada porta ut et justo. Integer convallis quis ligula vitae sollicitudin. Nulla sit amet tincidunt nulla, eget vestibulum massa. + +Interdum et malesuada fames ac ante ipsum primis in faucibus. Curabitur nec dui +ut lacus vehicula aliquet. Curabitur ac tincidunt risus. Etiam in eros massa. +Curabitur scelerisque orci non justo sollicitudin bibendum. Sed quis risus +imperdiet, tincidunt quam eget, consectetur eros. Maecenas sollicitudin augue +quis odio fermentum, eget elementum ipsum maximus. Etiam quam odio, tempus nec +posuere at, facilisis nec turpis. + +Curabitur porttitor sit amet odio ac rutrum. +Integer bibendum metus eu bibendum porttitor. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Konstantin Ryabitsev Date: Tue, 25 Oct 2022 13:38:45 -0400 Subject: [PATCH 4/4] Minor typo changes imitation MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: <20221025-test1-v1-4-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> In-Reply-To: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> To: list@example.org Life imitatus artem. Signed-off-by: Konstantin Ryabitsev --- lipsum.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lipsum.txt b/lipsum.txt index 750dabb..d304f7f 100644 --- a/lipsum.txt +++ b/lipsum.txt @@ -13,10 +13,10 @@ vitae sollicitudin. Nulla sit amet tincidunt nulla, eget vestibulum massa. Interdum et malesuada fames ac ante ipsum primis in faucibus. Curabitur nec dui ut lacus vehicula aliquet. Curabitur ac tincidunt risus. Etiam in eros massa. -Curabitur scelerisque orci non justo sollicitudin bibendum. Sed quis risus +Curabitur scelerisque orci non justo sollicitudin vivendum. Sed quis risus imperdiet, tincidunt quam eget, consectetur eros. Maecenas sollicitudin augue quis odio fermentum, eget elementum ipsum maximus. Etiam quam odio, tempus nec posuere at, facilisis nec turpis. Curabitur porttitor sit amet odio ac rutrum. -Integer bibendum metus eu bibendum porttitor. +Floatus bibendum metus eu bibendum porttitor. -- b4 0.11.0-dev-58177 From mboxrd@git Thu Jan 1 00:00:00 1970 From: Follow Upper Date: Tue, 25 Oct 2022 13:38:45 -0400 Subject: Re: [PATCH 4/4] Minor typo changes imitation MIME-Version: 1.0 Content-Type: text/plain; charset="utf-8" Content-Transfer-Encoding: 8bit Message-Id: In-Reply-To: <20221025-test1-v1-4-e4f28f57990c@linuxfoundation.org> References: <20221025-test1-v1-0-e4f28f57990c@linuxfoundation.org> <20221025-test1-v1-4-e4f28f57990c@linuxfoundation.org> To: list@example.org > Life imitatus artem. > > Signed-off-by: Konstantin Ryabitsev Reviewed-by: Follow Upper -- Follow Upper b4-0.13.0/tests/samples/trailers-thread-with-followups.verify000066400000000000000000000032421456345453500242460ustar00rootroot00000000000000konstantin@linuxfoundation.org Minor typo changes imitation Life imitatus artem. Signed-off-by: Konstantin Ryabitsev Reviewed-by: Follow Upper Signed-off-by: Test Override --- konstantin@linuxfoundation.org Add some paragraphs to lipsum Mostly junk. As expected. Signed-off-by: Konstantin Ryabitsev Signed-off-by: Test Override --- konstantin@linuxfoundation.org Add more lines to file 1 This is a second patch in the series. It needed a paragraph with the words of wisdom. Signed-off-by: Konstantin Ryabitsev Signed-off-by: Test Override --- konstantin@linuxfoundation.org Remove line 2 from file2 Etiam in rhoncus lacus. Ut velit nisl, mollis ac commodo vitae, ultrices quis felis. Proin varius hendrerit volutpat. Pellentesque nec laoreet quam, eu ullamcorper mi. Donec ut purus ac sapien dignissim elementum eu ac ante. Mauris sed faucibus orci. Vivamus eleifend accumsan ultricies. Cras at erat nec mauris iaculis eleifend sit amet eu libero. Suspendisse auctor a erat at vestibulum. Nullam efficitur quis turpis quis sodales. Nunc elementum hendrerit arcu eget feugiat. Nulla placerat pellentesque metus, nec rutrum nulla porttitor vel. Ut tristique commodo sem, ac sollicitudin enim pharetra et. Mauris sed tellus vitae nunc sollicitudin fermentum. Phasellus dui elit, malesuada quis metus vel, blandit tristique felis. Aenean quis tempus enim. Signed-off-by: Konstantin Ryabitsev Signed-off-by: Test Override --- b4-0.13.0/tests/samples/trailers-with-tripledash.bundle000066400000000000000000000050401456345453500230510ustar00rootroot00000000000000# v2 git bundle -f435c12df7c0ecf20ab8937859e63cddffacabb4 Add lipsum.txt b463b75950edf7e55e943f42d3cc45de35ec802e HEAD PACK ˜xœ¥ANÅ0C÷9Å\ _IÛ$-Bˆ=°.0IgÚš|5Sôÿí©qXÚ’ŸmÙˆ€uïFD;aìpð]ÔZGÓMÑø`hbϤ9ôêŒeÉ÷Ý€1¶£uFk&ácÂ`6Föƒé-:…»,eƒ‡’«`–”áùŠ!I¥¸}ûuïßSÞ/\ö<¡¤’Oe›ïÀ8ç¼mk¡Ñ½Ö*–uM"ôà軡uîÚ¨žR>æÉõ\ .˜gªŽŠ¯ŒR‰éGïpZOJ½¤9ÓÔæ&\oþ6E5M£^—Táû,X.²!TÙ™OêZ¥ŠCxœ¥ÏANÄ à=§ø/0 P Ôã¬=ÁütpZh 5ÓÛÛ•ÐíËË—÷ÚFm¯¼¢HVÈ 8 B½áè¼é{;)Ê­¸Qn`]µœŠRFŠ•Ža ŽPjëϪàV¯îíV6x+¹6Ì-eø8Ð¥Véžï¿éëœòþˆeÏ[*¹+ÛôÂ3ˆQK®8g¾,KjþŽCo%ç'¨O]C€Z‚óN®· ­ÀœÖº/Œ½—Úæ¾ö|ïàZ+ùF¡cì3M™Â¥ÄxqÇÓß± Ú€ô”xœ¥ANC!@÷œb.ÐøøÆ]wêf~?± ÖÞ^ÜxMfõòò&3½2ƒ%£Y;);¡qFÏZjCAjO.:ƒe”Æ‹3VÎfGÚ³w‡0{ÇÓL¬¼ž¢ 5)Ö˜ã”ÀK_K…—’[ÇÜS†×†ÔÂýÇ/}<¥|ùZÊ%öTò¾Ôã(k­SóAO°“FJ˶¥ÞùÁÙM^K9‚‡OD°•Ê0|nÐ ,éÄ „x_Sƒ1cÉgìq…±°¯½¡Ûý~¸v› ÝtÛ®S:…àj•ÿýqÓuÜPÝIH!T mÈæÈ:/¥>VÇ€ŸÏ)êVÈ3®kúVé"ÞUŠ®ø…ä½+ÄšfrÉ$º¸Ê²¢ækvZŠzhønÑ‚æ¯9ñÂÙð,ÑdÉ€º$ßêÄâ= –òЄ¢hLMXP ‡I®ç SžàBpkúæ²Vij¹-D ON"wÅ≗ІSS(€C²¦ÏÜ2˜1d¹i7¢;eíÖJ}wxD£³` TÝBáø¤Ê ´¼ÏŒq•$ã2sW@Ÿ V=7(ä ufïÝ(9­é¾• ßÀOÃ+2ÂW$žý-t| kõeÑKb-ÆÖ–i±‡ ·å\’aB µQ¿ûÇ_κ‘œ@ÁJ;¹Ùè—&,u™:½ð]k+«ESn5*^«S®ÕÍ\‘€% 0Tç]ýÞ~¬‰µ*s.fÆ —ˆ-Lgü;5CfýËüŠÑðz‰ AÂË^e%_õ g.×bÓÍ!Dþfí _ÍX¨ÏôV4z†ÁUýáøÂw«¼)aš ‚0ð‘s“¬½_ýÛO¦~?[‰V xœ340031QHËÌI5Ô+©(a˜œÅÓñá¦Wûs¦Úoûì®=ïä-XlˆPdVd·ÆTfê‘_§ùf^ ûègªþàãáPE9™Å¥¹`U—Y¾3ÿ"uû*ÛÅS|S…Γÿß çÀ0@»+xœMQÛjä0 }÷WèB ¯û¶´æa—…íhb%ø2µ¬Ì~þ§ÓR0$–äsÓÛ®F8«&¡§9\:ílÄdZ6”’™ÃÛc eYj‰g™.Ä1JüÞî»¶G·ïÜé*©–Íh—˜ðÓè.)ú.´Ö”êDt¯-Õ•îj±æ!¼8›÷æ™ÄÉ[áñ=ôÖx¦go|ÕîÙuñÄäŠZ"ÄËâÙ¸ÌáOc1)†nÍø`æÞ¿àgúŲH¢´,Óz3ÌY ½Ä&M{¸y:´p›é¯Û e5$sõ¤Ÿ ±³|ÃJz•Vgz©/ñâ¸t™ ãŸf·a¬x‚-ÓТLœôÝ9ƒ_Ý!YW]NÓ§QIÚ'’¬FNb£ªtC°)öîÀ™~ŸÐ`k"ãMOÿ¦8KÿxWj™Â!IŒæg„ľ¹À3|~¤’tCÜÀùd…«ÛÎMzÍ&Є°ñ(`Ya|ì4_gÄ#¥ÓRÄRÜfº”sÓÃÝØi¯ '¬劬¬søè@ú°xœ ÉÈ,V¢´ÌœT#=..ªßùØøÜY07|\é bRž ÊTÆÁsxœM=NA …µi†K¸EŠö:*z$﬌æg3¶#º"%àô\!=WÀ»H$ÍHöØï{~oWן§®ãã©ûY!¿W_]8¾w7˜gà´ÈƒI·Öp`µ…"ŒÆÁFØÓ3GK˜xg¤—ÃA¹D­(4–YêNó¬N­ dÁ>œW)‘ϺÔJ-ðb¢¤¦ÄÑÇF_ßóžÊh¹‡Gagny!ε‘I×ð9”¶s3Ö"•fÚ졇¤Heδ­QX”ëÈ6Ô2µ¼^”À}þÕÀ“ø›ñ•óùÄ9/ºÊ“GåÙ…©ŠQ#@w²Áȉ…—p?ËS.Ò˜jSe­ „0;u±âÁ6ӿׇûTQ]|àaÉ|ÈK²sç_¥¿‚Úºÿé…uxœ+(˜#RÊ»z{™X{ d¯…×Ù­†œÉ{: ®ìyxœ[̽˜{C> sRfÒæ"fFvϼ’ÔôÔ¢É5¬êƒðíé†Bxœ+(˜#rãÇH󚘗' msgs.append(msg) dest = os.path.join(tmp_path, 'out') with open(dest, 'wb') as fh: b4.save_git_am_mbox(msgs, fh) 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 ', None), ('utility', 'Fixes', 'abcdef01234567890', None), ('utility', 'Link', 'https://msgid.link/some@msgid.here', None), ]), ('trailers-test-extinfo', [('person', 'Reviewed-by', 'Bogus Bupkes ', '[for the parts that are bogus]'), ('utility', 'Fixes', 'abcdef01234567890', None), ('person', 'Tested-by', 'Some Person ', ' [this person visually indented theirs]'), ('utility', 'Link', 'https://msgid.link/some@msgid.here', ' # initial submission'), ('person', 'Signed-off-by', 'Wrapped Persontrailer ', None), ]), ]) def test_parse_trailers(sampledir, source, expected): with open(f'{sampledir}/{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, 'addlink': True}, 'addmsgid', {'linktrailermask': 'Message-Id: <%s>'}), ('single', {}, {'addmysob': True, 'copyccs': True}, 'ordered', {'trailer-order': 'Cc,Tested*,Reviewed*,*'}), ('single', {'sloppytrailers': True}, {'addmysob': True}, 'sloppy', {}), ('with-cover', {}, {'addmysob': True}, 'defaults', {}), ('with-cover', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}), ('custody', {}, {'addmysob': True, 'copyccs': True}, 'unordered', {}), ('custody', {}, {'addmysob': True, 'copyccs': True}, 'ordered', {'trailer-order': 'Cc,Fixes*,Link*,Suggested*,Reviewed*,Tested*,*'}), ('custody', {}, {'addmysob': True, 'copyccs': True}, 'with-ignored', {'trailers-ignore-from': 'followup-reviewer1@example.com'}), ('partial-reroll', {}, {'addmysob': True}, 'defaults', {}), ('nore', {}, {}, 'defaults', {}), ('non-git-patch', {}, {}, 'defaults', {}), ('with-diffstat', {}, {}, 'defaults', {}), ('name-parens', {}, {}, 'defaults', {}), ('bare-address', {}, {}, 'defaults', {}), ]) def test_followup_trailers(sampledir, source, serargs, amargs, reference, b4cfg): b4.MAIN_CONFIG.update(b4cfg) lmbx = b4.LoreMailbox() for msg in mailbox.mbox(f'{sampledir}/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.BytesIO() b4.save_git_am_mbox(amsgs, ifh) with open(f'{sampledir}/trailers-followup-{source}-ref-{reference}.txt', 'r') as fh: assert ifh.getvalue().decode() == fh.read() @pytest.mark.parametrize('hval,verify,tr', [ ('short-ascii', 'short-ascii', 'encode'), ('short-unicôde', '=?utf-8?q?short-unic=C3=B4de?=', 'encode'), # Long ascii (('Lorem ipsum dolor sit amet consectetur adipiscing elit ' 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'), ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n' ' eiusmod tempor incididunt ut labore et dolore magna aliqua'), 'encode'), # Long unicode (('Lorem îpsum dolor sit amet consectetur adipiscing elît ' 'sed do eiusmod tempôr incididunt ut labore et dolôre magna aliqua'), ('=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n' ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n' ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?='), 'encode'), # Exactly 75 long ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', 'encode'), # Unicode that breaks on escape boundary ('Lorem ipsum dolor sit amet consectetur adipiscin elît', '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n =?utf-8?q?=C3=AEt?=', 'encode'), # Unicode that's just 1 too long ('Lorem ipsum dolor sit amet consectetur adipi elît', '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n =?utf-8?q?t?=', 'encode'), # A single address ('foo@example.com', 'foo@example.com', 'encode'), # Two addresses ('foo@example.com, bar@example.com', 'foo@example.com, bar@example.com', 'encode'), # Mixed addresses ('foo@example.com, Foo Bar ', 'foo@example.com, Foo Bar ', 'encode'), # Mixed Unicode ('foo@example.com, Foo Bar , Fôo Baz ', 'foo@example.com, Foo Bar , \n =?utf-8?q?F=C3=B4o_Baz?= ', 'encode'), ('foo@example.com, Foo Bar , Fôo Baz , "Quux, Foo" ', ('foo@example.com, Foo Bar , \n' ' =?utf-8?q?F=C3=B4o_Baz?= , "Quux, Foo" '), 'encode'), ('01234567890123456789012345678901234567890123456789012345678901@example.org, ä ', ('01234567890123456789012345678901234567890123456789012345678901@example.org, \n' ' =?utf-8?q?=C3=A4?= '), 'encode'), # Test for https://github.com/python/cpython/issues/100900 ('foo@example.com, Foo Bar , Fôo Baz , "Quûx, Foo" ', ('foo@example.com, Foo Bar , \n' ' =?utf-8?q?F=C3=B4o_Baz?= , \n =?utf-8?q?Qu=C3=BBx=2C_Foo?= '), 'encode'), # Test preserve ('foo@example.com, Foo Bar , Fôo Baz , "Quûx, Foo" ', 'foo@example.com, Foo Bar , Fôo Baz , \n "Quûx, Foo" ', 'preserve'), # Test decode ('foo@example.com, Foo Bar , =?utf-8?q?Qu=C3=BBx=2C_Foo?= ', 'foo@example.com, Foo Bar , \n "Quûx, Foo" ', 'decode'), ]) def test_header_wrapping(sampledir, hval, verify, tr): hname = 'To' if '@' in hval else "X-Header" wrapped = b4.LoreMessage.wrap_header((hname, hval), transform=tr) assert wrapped.decode() == f'{hname}: {verify}' wname, wval = wrapped.split(b':', maxsplit=1) if tr != 'decode': cval = b4.LoreMessage.clean_header(wval.decode()) assert cval == hval @pytest.mark.parametrize('pairs,verify,clean', [ ([('', 'foo@example.com'), ('Foo Bar', 'bar@example.com')], 'foo@example.com, Foo Bar ', True), ([('', 'foo@example.com'), ('Foo, Bar', 'bar@example.com')], 'foo@example.com, "Foo, Bar" ', True), ([('', 'foo@example.com'), ('Fôo, Bar', 'bar@example.com')], 'foo@example.com, "Fôo, Bar" ', True), ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx_Foo?=', 'quux@example.com')], 'foo@example.com, Quûx Foo ', True), ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com')], 'foo@example.com, "Quûx, Foo" ', True), ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com')], 'foo@example.com, =?utf-8?q?Qu=C3=BBx=2C_Foo?= ', False), ]) def test_format_addrs(pairs, verify, clean): formatted = b4.format_addrs(pairs, clean) assert formatted == verify b4-0.13.0/tests/test_ez.py000066400000000000000000000061551456345453500153160ustar00rootroot00000000000000import pytest # noqa import os import b4 import b4.ez import b4.mbox import b4.command @pytest.fixture(scope="function") def prepdir(gitdir): b4.MAIN_CONFIG.update({'prep-cover-strategy': 'branch-description'}) parser = b4.command.setup_parser() b4args = ['--no-stdin', '--no-interactive', '--offline-mode', 'prep', '-n', 'pytest'] cmdargs = parser.parse_args(b4args) b4.ez.cmd_prep(cmdargs) yield gitdir @pytest.mark.parametrize('mboxf, bundlef, rep, trargs, compareargs, compareout, b4cfg', [ ('trailers-thread-with-followups', None, None, [], ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups', {'shazam-am-flags': '--signoff'}), ('trailers-thread-with-cover-followup', None, None, [], ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-cover-followup', {'shazam-am-flags': '--signoff'}), # Test matching trailer updates by subject when patch-id changes ('trailers-thread-with-followups', None, (b'vivendum', b'addendum'), [], ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups', {'shazam-am-flags': '--signoff'}), # Test that we properly perserve commits with --- in them ('trailers-thread-with-followups', 'trailers-with-tripledash', None, [], ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups-and-tripledash', None), ]) def test_trailers(sampledir, prepdir, mboxf, bundlef, rep, trargs, compareargs, compareout, b4cfg): if b4cfg: b4.MAIN_CONFIG.update(b4cfg) config = b4.get_main_config() mfile = os.path.join(sampledir, f'{mboxf}.mbox') assert os.path.exists(mfile) if bundlef: bfile = os.path.join(sampledir, f'{bundlef}.bundle') assert os.path.exists(bfile) gitargs = ['pull', '--rebase', bfile] out, logstr = b4.git_run_command(None, gitargs) assert out == 0 else: assert config.get('shazam-am-flags') == '--signoff' if rep: with open(mfile, 'rb') as fh: contents = fh.read() contents = contents.replace(rep[0], rep[1]) tfile = os.path.join(prepdir, '.git', 'modified.mbox') with open(tfile, 'wb') as fh: fh.write(contents) else: tfile = mfile b4args = ['--no-stdin', '--no-interactive', '--offline-mode', 'shazam', '--no-add-trailers', '-m', tfile] parser = b4.command.setup_parser() cmdargs = parser.parse_args(b4args) with pytest.raises(SystemExit) as e: b4.mbox.main(cmdargs) assert e.type == SystemExit assert e.value.code == 0 cfile = os.path.join(sampledir, f'{compareout}.verify') assert os.path.exists(cfile) parser = b4.command.setup_parser() b4args = ['--no-stdin', '--no-interactive', '--offline-mode', 'trailers', '--update', '-m', mfile] + trargs cmdargs = parser.parse_args(b4args) b4.ez.cmd_trailers(cmdargs) out, logstr = b4.git_run_command(None, compareargs) assert out == 0 with open(cfile, 'r') as fh: cstr = fh.read() assert logstr == cstr b4-0.13.0/tests/test_mbox.py000066400000000000000000000025261456345453500156430ustar00rootroot00000000000000import pytest # noqa import os import b4 import b4.mbox import b4.command @pytest.mark.parametrize('mboxf, shazamargs, compareargs, compareout, b4cfg', [ ('shazam-git1-just-series', [], ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], 'shazam-git1-just-series-defaults', {}), ('shazam-git1-just-series', ['-H'], ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD..FETCH_HEAD'], 'shazam-git1-just-series-defaults', {}), ('shazam-git1-just-series', ['-M'], ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD^..'], 'shazam-git1-just-series-merged', {}), ]) def test_shazam(sampledir, gitdir, mboxf, shazamargs, compareargs, compareout, b4cfg): b4.MAIN_CONFIG.update(b4cfg) mfile = os.path.join(sampledir, f'{mboxf}.mbox') cfile = os.path.join(sampledir, f'{compareout}.verify') assert os.path.exists(mfile) assert os.path.exists(cfile) parser = b4.command.setup_parser() shazamargs = ['--no-stdin', '--no-interactive', '--offline-mode', 'shazam', '-m', mfile] + shazamargs cmdargs = parser.parse_args(shazamargs) with pytest.raises(SystemExit) as e: b4.mbox.main(cmdargs) assert e.type == SystemExit assert e.value.code == 0 out, logstr = b4.git_run_command(None, compareargs) assert out == 0 with open(cfile, 'r') as fh: cstr = fh.read() assert logstr == cstr b4-0.13.0/thanks-am-template.example000066400000000000000000000026251456345453500171740ustar00rootroot00000000000000# Lines starting with '#' will be removed # You can have two different templates for responding to # pull requests and for responding to patch series, though # in reality the difference will probably be one word: # "merged/pulled" vs. "applied". # Keeping it short and sweet is preferred. # On ${sentdate}, ${fromname} wrote: # quote will be limited to 5-6 lines, respecting paragraphs ${quote} # You can also use ${branch} and ${treename} if you set # b4.thanks-treename in your config, e.g.: #Applied to ${treename} (${branch}), thanks! # # If you track multiple remotes in the same repo, then you can add # the following values to [remote], to be loaded when you run # b4 ty -b foo/branchname: # [remote "foo"] # url = https://... # fetch = ... # b4-treename = uname/sound.git # b4-commit-url-mask = https://git.kernel.org/uname/sound/c/%.8s Applied to ${branch}, thanks! # for patch series, the summary is a list of each patch with a link # to the commit id in your tree, so you probably want to set # b4.thanks-commit-url-mask in gitconfig to a value like: # [b4] # thanks-commit-url-mask = https://git.kernel.org/username/c/%.12s # # Check this page for info on convenient URL shorteners: # https://korg.wiki.kernel.org/userdoc/git-url-shorterners ${summary} Best regards, -- # if ~/.signature exists, it will be put here, otherwise # the contents will be "user.name " from gitconfig ${signature} b4-0.13.0/thanks-pr-template.example000066400000000000000000000025731456345453500172220ustar00rootroot00000000000000# Lines starting with '#' will be removed # You can have two different templates for responding to # pull requests and for responding to patch series, though # in reality the difference will probably be one word: # "merged/pulled" vs. "applied". # Keeping it short and sweet is preferred. # On ${sentdate}, ${fromname} wrote: # quote will be limited to 5-6 lines, respecting paragraphs ${quote} # You can also use ${branch} and ${treename} if you set # b4.thanks-treename in your config, e.g.: #Merged into ${treename} (${branch}), thanks! # # If you track multiple remotes in the same repo, then you can add # the following values to [remote], to be loaded when you run # b4 ty -b foo/branchname: # [remote "foo"] # url = https://... # fetch = ... # b4-treename = uname/sound.git # b4-commit-url-mask = https://git.kernel.org/uname/sound/c/%.8s Merged into ${branch}, thanks! # for pull requests, the summary is a one-liner with the merge commit, # so you probably want to set b4.thanks-commit-url-mask in gitconfig # to a value like: # [b4] # thanks-commit-url-mask = https://git.kernel.org/username/c/%.12s # # Check this page for info on convenient URL shorteners: # https://korg.wiki.kernel.org/userdoc/git-url-shorterners ${summary} Best regards, -- # if ~/.signature exists, it will be put here, otherwise # the contents will be "user.name " from gitconfig ${signature} pax_global_header00006660000000000000000000000064143643055060014520gustar00rootroot0000000000000052 comment=1f6c72cd4cb026ccffb453972cff009a34c2e1a5 b4-0.13.0/patatt/000077500000000000000000000000001436430550600134035ustar00rootroot00000000000000b4-0.13.0/patatt/.gitignore000066400000000000000000000001061436430550600153700ustar00rootroot00000000000000*~ *.pyc *.swp __pycache__ .venv .idea *.egg-info build dist outgoing b4-0.13.0/patatt/.keys/000077500000000000000000000000001436430550600144345ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/ed25519/000077500000000000000000000000001436430550600154325ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/ed25519/kernel.org/000077500000000000000000000000001436430550600175005ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/ed25519/kernel.org/mricon/000077500000000000000000000000001436430550600207675ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/ed25519/kernel.org/mricon/20210505000066400000000000000000000000541436430550600216070ustar00rootroot00000000000000i+0Am6o59VU+dAfK4WhkCl56BrA+rY4cXlq3AbO5M8c=b4-0.13.0/patatt/.keys/openpgp/000077500000000000000000000000001436430550600161045ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openpgp/chromium.org/000077500000000000000000000000001436430550600205155ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openpgp/chromium.org/keescook/000077500000000000000000000000001436430550600223205ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openpgp/chromium.org/keescook/default000066400000000000000000002434201436430550600236740ustar00rootroot00000000000000pub rsa4096 2010-09-27 [SC] A5C3F68F229DD60F723E6E138972F4DFDC6DC026 uid Kees Cook uid Kees Cook uid Kees Cook uid Kees Cook uid Kees Cook uid Kees Cook 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----- b4-0.13.0/patatt/.keys/openpgp/linuxfoundation.org/000077500000000000000000000000001436430550600221205ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openpgp/linuxfoundation.org/konstantin/000077500000000000000000000000001436430550600243105ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openpgp/linuxfoundation.org/konstantin/default000066400000000000000000000561461436430550600256730ustar00rootroot00000000000000-----BEGIN PGP PUBLIC KEY BLOCK----- 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 eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS 09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAA AAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEH YXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVG R0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKj pKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX2 9/j5+v/aAAwDAQACEQMRAD8A+y6KKKACiiory4gtLWW6uZVihhQvI7HAVQMkn8KA JaxvEPivw34fXdrWt2Nj6LLMAx+i9TXyl8bf2kNb1G7n0jwSz6fYqxT7SBiaUeoP 8I+nNfP13b+IPEN0097qshkckszuWLfnQB+g4+M3w4M4hXxJEzHoRE5H8q6HSvGf hfVHVLLWbWRm+6CSufzxX53aL4dmtCHlvd7A54Y816r4JvprZESQs6AjkHnHrQB9 tg5GRRXAfCHxBNqNlJpt05kaFBJC5OSUPBB+h/nXf0AFFFFABRRRQAUUUUAFcJ+0 BLND8HvEbwOUk+y4BB9WUEfka5r4nftFfDnwLfzabc30upahCdssNmAwjb0ZyQAf YZrw74tftV+F/GXgbUfDVno2oWTXgRftDOrgKHDH5RjrjHWgDwwmJrlpT97uc8fh U6X0SOpjkAI64NctHdaHNcyTXOr3JTf8sIh8vK+7c4P4Vct77QwcW+nRXOAeZL1x k54P3R+X60AdjYapaGVVMjOOpx2Ndb4av1O4xSlVJ43L09q85025aYDbYeHICVx+ +vApB9e/Htz9a6zR4tZZg1rH4UYEYYJfE/iBjj6cigD6B+FfjCLSdXtpXdXib91L g8bT1x+hr6TjdZI1kRgysAQR3Br4o8OWPiMeWWstBbJyfKmbOc/7np/kV9H/AAp8 XTtY2+h69CLe4jUJbyrlo3UDoW/hI7ZxmgD0qio2mhVdzSoF9SwxTba6trnd9nni l2nDbGDY/KgCaiiigAryv9qvxpd+Bfgtq+q6dKYr+4KWVtIOsbSHBYe4UMR716pX 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 =chX+ -----END PGP PUBLIC KEY BLOCK----- b4-0.13.0/patatt/.keys/openssh/000077500000000000000000000000001436430550600161135ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openssh/linuxfoundation.org/000077500000000000000000000000001436430550600221275ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openssh/linuxfoundation.org/konstantin/000077500000000000000000000000001436430550600243175ustar00rootroot00000000000000b4-0.13.0/patatt/.keys/openssh/linuxfoundation.org/konstantin/20211115000066400000000000000000000001441436430550600251350ustar00rootroot00000000000000ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKggcAE07YvL/ULFiQeMdclQO9qM1apV8I3GfyJkYz2b user@meerkat.local b4-0.13.0/patatt/COPYING000066400000000000000000000016411436430550600144400ustar00rootroot00000000000000The MIT-Zero License Copyright (C) 2021-2022 by the Linux Foundation Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. b4-0.13.0/patatt/DCO000066400000000000000000000026151436430550600137370ustar00rootroot00000000000000Developer Certificate of Origin Version 1.1 Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 1 Letterman Drive Suite D4700 San Francisco, CA, 94129 Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Developer's Certificate of Origin 1.1 By making a contribution to this project, I certify that: (a) The contribution was created in whole or in part by me and I have the right to submit it under the open source license indicated in the file; or (b) The contribution is based upon previous work that, to the best of my knowledge, is covered under an appropriate open source license and I have the right under that license to submit that work with modifications, whether created in whole or in part by me, under the same open source license (unless I am permitted to submit under a different license), as indicated in the file; or (c) The contribution was provided directly to me by some other person who certified (a), (b) or (c) and I have not modified it. (d) I understand and agree that this project and the contribution are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with this project or the open source license(s) involved. b4-0.13.0/patatt/MANIFEST.in000066400000000000000000000001011436430550600151310ustar00rootroot00000000000000include COPYING include DCO include README.rst include man/*.rst b4-0.13.0/patatt/README.rst000066400000000000000000000534211436430550600150770ustar00rootroot00000000000000patatt: cryptographic patch attestation for the masses ====================================================== This utility allows an easy way to add end-to-end cryptographic attestation to patches sent via mail. It does so by adapting the DKIM email signature standard to include cryptographic signatures via the X-Developer-Signature email header. If your project workflow doesn't use patches sent via email, then you don't need this and should simply start signing your tags and commits. Basic concepts -------------- DKIM is a widely adopted standard for domain-level attestation of email messages. It works by hashing the message body and certain individual headers, and then creating a cryptographic signature of the resulting hash. The receiving side obtains the public key of the sending domain from its DNS record and checks the signature and header/body hashes. If the signature verifies and the resulting hashes are identical, then there is a high degree of assurance that neither the body of the message nor any of the signed headers were modified in transit. This utility uses the exact same DKIM standard to hash the headers and the body of the patch message, but uses a different set of fields and canonicalization routines: - the d= field is not used (no domain signatures involved) - the q= field is not used (key lookup is left to the client) - the c= field is not used (see below for canonicalization) - the i= field is optional, but MUST be the canonical email address of the sender, if not the same as the From: field Canonicalization ~~~~~~~~~~~~~~~~ Patatt uses the "relaxed/simple" canonicalization as defined by the DKIM standard, but the message is first parsed by the "git-mailinfo" command in order to achieve the following: - normalize any content-transfer-encoding modifications (convert back from base64/quoted-printable/etc into 8-bit) - use any encountered in-body From: and Subject: headers to rewrite the outer message headers - perform the subject-line normalization in order to strip content not considered by git-am when applying the patch (i.e. drop [PATCH .*] and other bracketed prefix content) To achieve this, the message is passed through git-mailinfo with the following flags:: cat orig.msg | git mailinfo --encoding=utf-8 --no-scissors m p > i Patatt then uses the data found in "i" to replace the From: and Subject: headers of the original message, and concatenates "m" and "p" back together to form the body of the message, which is then normalized using CRLF line endings and the DKIM "simple" body canonicalization (any trailing blank lines are removed). Any other headers included in signing are modified using the "relaxed" header canonicalization routines as defined in the DKIM RFC. In other words, the body and some of the headers are normalized and reconstituted using the "git-mailinfo" command, and then canonicalized using DKIM's relaxed/simple standard. Supported Signature Algorithms ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ DKIM standard mostly relies on RSA signatures, though RFC 8463 extends it to support ED25519 keys as well. While it is possible to use any of the DKIM-defined algorithms, patatt only supports the following signing/hashing schemes: - ed25519-sha256: exactly as defined in RFC8463 - openpgp-sha256: uses OpenPGP to create the signature - openssh-sha256: uses OpenSSH signing capabilities Note: Since GnuPG supports multiple signing key algorithms, openpgp-sha256 signatures can be done using EDDSA keys as well. However, since OpenPGP output includes additional headers, the "ed25519-sha256" and "openpgp-sha256" schemes are not interchangeable even when ed25519 keys are used in both cases. Note: OpenSSH signature support was added in OpenSSH 8.0 and requires ssh-keygen that supports the -Y flag. In the future, patatt may add support for more algorithms, especially if that allows incorporating more hardware crypto offload devices (such as TPM). X-Developer-Key header ~~~~~~~~~~~~~~~~~~~~~~ Patatt adds a separate ``X-Developer-Key:`` header with public key information. It is merely informational and ISN'T and SHOULDN'T be used for performing any kind of message validation (for obvious reasons). It is included to make it easier for maintainers to obtain the contributor's public key before performing whatever necessary verification steps prior to its inclusion into their individual or project-wide keyrings. This also allows keeping a historical record of contributor keys via list archive services such as lore.kernel.org and others. Getting started as contributor ------------------------------ It is very easy to start signing your patches with patatt. Installing ~~~~~~~~~~ You can install from pip:: pip install --user patatt Make sure your PATH includes $HOME/.local/bin. Alternatively, you can clone this repository and symlink patatt.sh into your path:: cd bin ln -s ~/path/to/patatt/patatt.sh patatt After this, you should be able to run ``patatt --help`` without specifying the full path to the repository. Using PGP ~~~~~~~~~ If you already have a PGP key, you can simply start using it to sign patches. Add the following to your ~/.gitconfig:: [patatt] signingkey = openpgp:KEYID The KEYID should be the 16-character identifier of your key, for example:: [patatt] signingkey = openpgp:E63EDCA9329DD07E Using OpenSSH ~~~~~~~~~~~~~ If you have OpenSSH version 8.0+, then you can use your ssh keys for generating and verifying signatures. There are several upsides to using openssh as opposed to generic ed25519: - you can passphrase-protect your ssh keys - passphrase-protected keys will benefit from ssh-agent caching - you can use hardware tokens and ed25519-sk keys for higher protection - you are much more likely to remember to back up your ssh keys To start using openssh signatures with patatt, add the following to your ~/.gitconfig:: [patatt] signingkey = openssh:~/.ssh/my_key_id.pub selector = my_key_id Note, that the person verifying openssh signatures must also run the version of openssh that supports this functionality. Using ed25519 ~~~~~~~~~~~~~ If you don't already have a PGP key, you can opt to generate and use a new ed25519 key instead (see below for some considerations on pros and cons of PGP vs ed25519 keys). To generate a new keypair, run:: patatt genkey You will see an output similar to the following:: Generating a new ed25519 keypair Wrote: /home/user/.local/share/patatt/private/20210505.key Wrote: /home/user/.local/share/patatt/public/20210505.pub Wrote: /home/user/.local/share/patatt/public/ed25519/example.org/user/default Add the following to your .git/config (or global ~/.gitconfig): --- [patatt] signingkey = ed25519:20210505 --- Next, communicate the contents of the following file to the repository keyring maintainers for inclusion into the project: /home/user/.local/share/patatt/public/20210505.pub Please make sure to back up your new private key, located in ``~/.local/share/patatt/private``. It is short enough to simply print/write out for storing offline. Next, just do as instructions say. If the project for which you are contributing patches already uses patatt attestation, please work with the project maintainers to add your public key to the repository. If they aren't yet using patatt, just start signing your patches and hopefully the project will start keeping its own keyring in the future. Testing if it's working ~~~~~~~~~~~~~~~~~~~~~~~ To test if it's working:: $ git format-patch -1 --stdout | patatt sign > /tmp/test If you didn't get an error message, then the process was successful. You can review /tmp/test to see that ``X-Developer-Signature`` and ``X-Developer-Key`` headers were successfully added. You can now validate your own message:: $ patatt validate /tmp/test Automatic signing via the sendemail-validate hook ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If everything is working well, you can start automatically signing all outgoing patches sent via git-send-email. Inside the repo you want enabled for signing, run:: $ patatt install-hook Or you can do it manually:: $ echo 'patatt sign --hook "${1}"' > "$(git rev-parse --git-dir)/hooks/sendemail-validate" $ chmod a+x "$(git rev-parse --git-dir)/hooks/sendemail-validate" PGP vs OpenSSH vs ed25519 keys considerations ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ If you don't already have a PGP key that is used in your project, you may wonder whether it makes sense to create a new PGP key, reuse your OpenSSH key, or start using standalone ed25519 keys. Reasons to choose PGP: - you can protect the PGP private key with a passphrase (gpg-agent will manage it for you so you only need to enter it once per session) - you can move your PGP key to an OpenPGP-compliant smartcard to further protect your key from being leaked/stolen - you can use PGP keys to sign git tags/commits, not just mailed patches If you choose to create a new PGP key, you can use the following guide: https://github.com/lfit/itpol/blob/master/protecting-code-integrity.md Reasons to choose OpenSSH keys: - you can protect openssh keys with a passphrase and rely on ssh-agent passphrase caching - you can use ssh keys with u2f hardware tokens for additional protection of your private key data - very recent versions of git can also use ssh keys to sign tags and commits Reasons to choose a standalone ed25519 key: - much smaller signatures, especially compared to PGP RSA keys - implements the DKIM ed25519 signing standard - faster operation If you choose ed25519 keys, you will need to make sure that PyNaCl is installed (pip install should have already taken care of it for you). Getting started as a project maintainer --------------------------------------- Patatt implements basic signature validation, but it's a tool aimed primarily at contributors. If you are processing mailed-in patches, then you should look into using b4, which aims at making the entire process easier. B4 properly recognizes X-Developer-Signature headers starting with version 0.7.0 and uses the patatt library as well. - https://pypi.org/project/b4/ That said, keyring management as discussed below applies both to patatt and b4, so you can read on for an overview. In-git pubkey management ~~~~~~~~~~~~~~~~~~~~~~~~ The trickiest part of all decentralized PKI schemes is not the crypto itself, but public key distribution and management. PGP famously tried to solve this problem by relying on cross-key certification and keyservers, but the results were not encouraging. On the other hand, within the context of git repositories, we already have a suitable mechanism for distributing developer public keys, which is the repository itself. Consider this: - git is already decentralized and can be mirrored to multiple locations, avoiding any single points of failure - all contents are already versioned and key additions/removals can be audited and "git blame'd" - git commits themselves can be cryptographically signed, which allows a small subset of developers to act as "trusted introducers" to many other contributors (mimicking the "keysigning" process) The idea of using git itself for keyring management was originally suggested by the did:git project, though we do not currently implement the proposed standard itself. - https://github.com/dhuseby/did-git-spec/blob/master/did-git-spec.md Keyring structure ~~~~~~~~~~~~~~~~~ The keyring is structured as follows:: - dir: topdir (e.g. ".keys") | - dir: keytype (e.g. "ed25519" or "openpgp") | - dir: address-domainname (e.g. "example.org") | - dir: address-localpart (e.g. "developer") | - file: selector (e.g. "default") The main reasoning behind this structure was to make it easy for multiple project maintainers to manage keys without causing any unnecessary git merge complications. Keeping all public keys in individual files helps achieve this goal. For example, let's take the following signature:: From: Konstantin Ryabitsev X-Developer-Signature: v=1; a=ed25519-sha256; t=1620240207; l=2577; h=from:subject; bh=yqviDBgyf3/dQgHcBe3B7fTP39SuKnYInPBxnOiuGcA=; b=Xzd0287MvPE9NLX7xbQ6xnyrvqQOMK01mxHnrPmm1f6O7KKyogc8YH6IAlwIPdo+jk1CkdYYQsyZ sS0cJdX2B4uTmV9mxOe7hssjtjLcj5/NU9zAw6WJARybaNAKH8rv The key would be found in the following subpath:: .keys/ed25519/linuxfoundation.org/konstantin/default If i= and s= fields are specified in the signature, as below:: X-Developer-Signature: v=1; a=ed25519-sha256; t=1620244687; l=12645; i=mricon@kernel.org; s=20210505; h=from:subject; bh=KRCBcYiMdeoSX0l1XJ2YzP/uJhmym3Pi6CmbN9fs4aM=; b=sSY2vXzju7zU3KK4VQ5vFa5iPpDr3nrf221lnpq2+uuXmCODlAsgoqDmjKUBmbPtlY1Bcb2N0XZQ 0KX+OShCAAwB5U1dtFtRnB/mgVibMxwl68A7OivGIVYe491yll5q Then the path would reflect those parameters:: .keys/ed25519/kernel.org/mricon/20210505 In the case of ed25519 keys, the contents of the file are just the base64-encoded public key itself. For openpgp keys, the format should be the ascii-armored public key export, for example obtained by using the following command:: gpg -a --export --export-options export-minimal keyid For openssh keys, the key contents are a single line in the usual openssh pubkey format, e.g.:: ssh-ed25519 AAAAC3N... comment@or-hostname Whose keys to add to the keyring ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ It does not really make sense to require cryptographic attestation for patches submitted by occasional contributors. The only keys added to the keyring should be those of the core maintainers who have push access to the "canonical" repository location, plus the keys belonging to regular contributors with a long-term ongoing relationship with the project. Managing the keyring: small teams ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For smaller repositories with a handful of core maintainers, it makes sense to keep the keyring in the main branch, together with all other project files. Managing the keyring: large teams ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ For large teams with thousands of regular contributors and teams of subsystem maintainers (e.g. the Linux kernel), it does not make sense to have a centrally managed keyring tracked in the main repository. Instead, each subsystem maintainer team should manage their own keyring in a separate ref of their own repository. For example, to create a blank new ref called ``refs/meta/keyring``:: git symbolic-ref HEAD refs/meta/keyring git reset --hard mkdir ed25519 openpgp Individual public key files can then be added and committed following the same structure as described above. Keeping the keyring outside the regular development branch ensures that it doesn't interfere with submitted pull requests or git-format-patch operations. Keeping the ref under ``refs/meta/`` will hide it from most GUI interfaces, but if that is not the goal, then it can be stored in ``refs/heads`` just like any other branch. To commit and push the files after adding them, regular git operations should be used:: git commit -asS git push origin HEAD:refs/meta/keyring # Switch back to the development environment git checkout regular-branch To make changes to an existing keyring ref, a similar workflow can be used:: git fetch origin refs/meta/keyring # Verify that the commit is signed git verify-commit FETCH_HEAD git checkout FETCH_HEAD # make any changes to the keys git commit -asS git push origin HEAD:refs/meta/keyring git checkout regular-branch Alternatively, if key additions/updates are frequent enough, the remote ref can be checked out into its own workdir and set up for proper remote tracking. Telling patatt where to find the keyring(s) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To use the keyring with patatt or b4, just tell them which paths to check, via the ``keyringsrc`` setting (can be specified multiple times and will be checked in the listed order):: [patatt] # Empty ref means "use currently checked out ref in this repo" keyringsrc = ref:::.keys # Use a dedicated ref in this repo called refs/meta/keyring keyringsrc = ref::refs/meta/keyring: # Use a ref in a different repo keyringsrc = ref:~/path/to/another/repo:refs/heads/main:.keys # Use a regular dir on disk keyringsrc = ~/git/korg-pgpkeys/.keyring For b4, use the same configuration under the ``[b4]`` section. External and local-only keyrings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Any path on disk can be used for a keyring location, and some will always be checked just in case. The following locations are added by default:: ref:::.keys ref:::.local-keys ref::refs/meta/keyring: $XDG_DATA_HOME/patatt/public The ":::" means "whatever ref is checked out in the current repo", and $XDG_DATA_HOME usually points at $HOME/.local/share. Getting support and contributing patches ---------------------------------------- Please send patches and support requests to tools@linux.kernel.org. Submissions must be made under the terms of the Linux Foundation certificate of contribution and should include a Signed-off-by: line. Please read the DCO file for full legal definition of what that implies. Frequently seen commentary -------------------------- Why is this library even needed? Why not... Why not simply PGP-sign all patches? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ PGP-signing patches causes important problems for reviewers. If a patch is inline-signed, then this not only adds textual headers/footers, but adds additional escaping in the protected body, converting all '^-' sequences into '^- -', which corrupts patches. MIME-signing is better, but has several other downsides: - messages are now sent as multipart mime structures, which causes some tooling to no longer properly handle the patch content - the signature attachments may be stripped/quarantined by email gateways that don't properly recognize OpenPGP mime signatures - the From/Subject headers are rarely included into protected content, even though they are crucial parts of what ends up going into a git commit These considerations have resulted in many projects specifically requesting that patches should NOT be sent PGP-signed. Why not just rely on proper code review? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Code review is a crucial step of the development process and patatt does not aim to replace it. However, there are several areas where the process can be abused by malicious parties in the absence of end-to-end cryptographic attestation: 1. A maintainer who struggles with code review volume may delegate parts of their duties to a submaintainer. If that person submits aggregated patch series to the maintainer after performing that work, there must be a mechanism to ensure that none of the reviewed patches have been modified between when they were reviewed by the trusted submaintainer and when the upstream developer applies them to their tree. Up to now, the only mechanism to ensure this was via signed pull requests -- with patatt this is now also possible with regular patch series. 2. It is important to ensure that what developer reviews is what actually ends up being applied to their git tree. Linux development process consists of collecting follow-up trailers (Tested-by, Reviewed-by, etc), so various tooling exists to aggregate these trailers and create the collated patch series containing all follow-up tags (see b4, patchwork, etc). Patatt signing provides a mechanism to ensure that what that developer reviewed and approved and what they applied to their tree is the exact same code and hasn't been maliciously modified in-between review and "git am" (e.g. by archival services such as lore.kernel.org, mail hosting providers, someone with access to the developer's inbox, etc). 3. An attacker may attempt to impersonate a well-known developer by submitting malicious code, perhaps with the hope that it receives less scrutiny and is accepted without rigorous code review. Even if this attempt is unsuccessful (and it most likely would be), this may cause unnecessary reputation damage to the person being impersonated. Cryptographic signatures (and lack thereof) will help the developer quickly establish that the attack was performed without their involvement. Why not just rely on DKIM? ~~~~~~~~~~~~~~~~~~~~~~~~~~ DKIM standard is great, but there are several places where it falls a bit short when it comes to patch attestation: 1. The signing is done by the mail gateways that may or may not be properly checking that the "From:" header matches the identity of the authenticated user. For example, a service that allows free account registration may not check that alice@example.org sends outgoing email with "bob@example.org" in the "From:" field, which would allow Alice to impersonate Bob and have the messages arrive with a valid DKIM signature. 2. DKIM is usually seen as merely a spam reduction mechanism, so there's usually little incentive for infrastructure administrators to be too strict about how they handle the private keys used for DKIM signing. Most likely, they are just stored on disk without a passphrase and accessible by the SMTP daemon. 3. DKIM's "relaxed" canonicalization standard for message bodies replaces all multiple whitespace characters with a single space before the body hash is signed. This poses significant problems for patches where whitespace is syntactically significant (Python, Makefiles, etc). A "return True" with a different indent will pass DKIM signature check and may introduce a serious security vulnerability. 4. DKIM doesn't prevent typosquatting attacks. For example, an attacker attempting to impersonate known.developer@companyname.com may send an email from known.developer@company-name.com or any other similar-looking address or domain, with valid DKIM signatures in every case. b4-0.13.0/patatt/man/000077500000000000000000000000001436430550600141565ustar00rootroot00000000000000b4-0.13.0/patatt/man/patatt.5000066400000000000000000000057721436430550600155540ustar00rootroot00000000000000.\" Man page generated from reStructuredText. . . .nr rst2man-indent-level 0 . .de1 rstReportMargin \\$1 \\n[an-margin] level \\n[rst2man-indent-level] level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] - \\n[rst2man-indent0] \\n[rst2man-indent1] \\n[rst2man-indent2] .. .de1 INDENT .\" .rstReportMargin pre: . RS \\$1 . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] . nr rst2man-indent-level +1 .\" .rstReportMargin post: .. .de UNINDENT . RE .\" indent \\n[an-margin] .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] .nr rst2man-indent-level -1 .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] .in \\n[rst2man-indent\\n[rst2man-indent-level]]u .. .TH "PATATT" 5 "2022-08-22" "0.6.0" "" .SH NAME PATATT \- DKIM-like cryptographic patch attestation .SH SYNOPSIS .sp patatt {sign,validate,genkey,install\-hook} [options] .SH DESCRIPTION .sp This tools allows cryptographically signing patches sent via email by using DKIM\-like message headers. This approach is both effective and doesn\(aqt interfere with other code review tools the way inline or detached PGP signatures do. For a full overview of core concepts and considerations, please see README. .sp If you already have a PGP key configured for signing git tags or commits, then you should be able to use patatt without any additional configuration. Try running the following in any git repository: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C git format\-patch \-1 \-\-stdout | patatt sign .ft P .fi .UNINDENT .UNINDENT .sp If patatt is not finding your PGP key, try adding the following to your ~/.gitconfig: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C [user] signingkey = [yourkeyid] .ft P .fi .UNINDENT .UNINDENT .sp To find out your keyid, run \fBgpg \-\-list\-secret\-keys\fP\&. If you want to use a specific subkey, you can specify the subkey ID with a \fB!\fP at the end. .SH USING AS A GIT HOOK .sp If you use \fBgit\-send\-email\fP for sending patches, then you can get them automatically signed via the \fBsendemail\-validate\fP hook. To install, run the following command in the repository you want enabled for signing: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ patatt install\-hook .ft P .fi .UNINDENT .UNINDENT .sp Or you can install it manually: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C $ echo \(aqpatatt sign \-\-hook "${1}"\(aq >> .git/hooks/sendemail\-validate $ chmod a+x .git/hooks/sendemail\-validate .ft P .fi .UNINDENT .UNINDENT .SH SUBCOMMANDS .INDENT 0.0 .IP \(bu 2 \fIsign\fP: sign stdin or RFC2822 files passed as arguments .IP \(bu 2 \fIvalidate\fP: basic validation for signed messages .IP \(bu 2 \fIgenkey\fP: generate a new ed25519 keypair .IP \(bu 2 \fIinstall\-hook\fP: install sendemail\-validate hook in the current repository .UNINDENT .sp You can run \fBpatatt [subcommand] \-\-help\fP to see a summary of flags for each subcommand. .SH SUPPORT .sp Please email \fI\%tools@linux.kernel.org\fP with support requests. .SH AUTHOR mricon@kernel.org License: MIT-0 .SH COPYRIGHT The Linux Foundation and contributors .\" Generated by docutils manpage writer. . b4-0.13.0/patatt/man/patatt.5.rst000066400000000000000000000040571436430550600163560ustar00rootroot00000000000000PATATT ====== ----------------------------------------- DKIM-like cryptographic patch attestation ----------------------------------------- :Author: mricon@kernel.org :Date: 2022-08-22 :Copyright: The Linux Foundation and contributors :License: MIT-0 :Version: 0.6.0 :Manual section: 5 SYNOPSIS -------- patatt {sign,validate,genkey,install-hook} [options] DESCRIPTION ----------- This tools allows cryptographically signing patches sent via email by using DKIM-like message headers. This approach is both effective and doesn't interfere with other code review tools the way inline or detached PGP signatures do. For a full overview of core concepts and considerations, please see README. If you already have a PGP key configured for signing git tags or commits, then you should be able to use patatt without any additional configuration. Try running the following in any git repository:: git format-patch -1 --stdout | patatt sign If patatt is not finding your PGP key, try adding the following to your ~/.gitconfig:: [user] signingkey = [yourkeyid] To find out your keyid, run ``gpg --list-secret-keys``. If you want to use a specific subkey, you can specify the subkey ID with a ``!`` at the end. USING AS A GIT HOOK ------------------- If you use ``git-send-email`` for sending patches, then you can get them automatically signed via the ``sendemail-validate`` hook. To install, run the following command in the repository you want enabled for signing:: $ patatt install-hook Or you can install it manually:: $ echo 'patatt sign --hook "${1}"' >> .git/hooks/sendemail-validate $ chmod a+x .git/hooks/sendemail-validate SUBCOMMANDS ----------- * *sign*: sign stdin or RFC2822 files passed as arguments * *validate*: basic validation for signed messages * *genkey*: generate a new ed25519 keypair * *install-hook*: install sendemail-validate hook in the current repository You can run ``patatt [subcommand] --help`` to see a summary of flags for each subcommand. SUPPORT ------- Please email tools@linux.kernel.org with support requests. b4-0.13.0/patatt/patatt.sh000077500000000000000000000003651436430550600152430ustar00rootroot00000000000000#!/usr/bin/env bash # # Run patatt from a git checkout. # REAL_SCRIPT=$(realpath -e ${BASH_SOURCE[0]}) SCRIPT_TOP="${SCRIPT_TOP:-$(dirname ${REAL_SCRIPT})}" exec env PYTHONPATH="${SCRIPT_TOP}" python3 "${SCRIPT_TOP}/patatt/__init__.py" "${@}" b4-0.13.0/patatt/patatt/000077500000000000000000000000001436430550600147005ustar00rootroot00000000000000b4-0.13.0/patatt/patatt/__init__.py000066400000000000000000001436541436430550600170260ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # Copyright (C) 2021-2022 by The Linux Foundation # SPDX-License-Identifier: MIT-0 # __author__ = 'Konstantin Ryabitsev ' import sys import os import re import hashlib import base64 import subprocess import logging import tempfile import time import datetime import urllib.parse import email.utils import email.header from pathlib import Path from typing import Optional, Tuple, Union from io import BytesIO logger = logging.getLogger(__name__) # Overridable via [patatt] parameters GPGBIN = None SSHKBIN = None # Hardcoded defaults DEVSIG_HDR = b'X-Developer-Signature' DEVKEY_HDR = b'X-Developer-Key' # Result and severity levels RES_VALID = 0 RES_NOSIG = 4 RES_NOKEY = 8 RES_ERROR = 16 RES_BADSIG = 32 REQ_HDRS = [b'from', b'subject'] OPT_HDRS = [b'message-id'] # Quick cache for key info KEYCACHE = dict() # Quick cache for config settings CONFIGCACHE = dict() # My version __VERSION__ = '0.6.3' MAX_SUPPORTED_FORMAT_VERSION = 1 class SigningError(Exception): def __init__(self, message: str, errors: Optional[list] = None): super().__init__(message) self.errors = errors class ConfigurationError(Exception): def __init__(self, message: str, errors: Optional[list] = None): super().__init__(message) self.errors = errors class ValidationError(Exception): def __init__(self, message: str, errors: Optional[list] = None): super().__init__(message) self.errors = errors class NoKeyError(ValidationError): def __init__(self, message: str, errors: Optional[list] = None): super().__init__(message) self.errors = errors class BodyValidationError(ValidationError): def __init__(self, message: str, errors: Optional[list] = None): super().__init__(message, errors) class DevsigHeader: def __init__(self, hval: Optional[bytes] = None): self._headervals = list() self._body_hash = None # it doesn't need to be in any particular order, # but that's just anarchy, anarchy, I say! self._order = ['v', 'a', 't', 'l', 'i', 's', 'h', 'bh'] self.hval = None self.hdata = dict() if hval: self.from_bytes(hval) else: self.hdata['v'] = b'1' def from_bytes(self, hval: bytes) -> None: self.hval = DevsigHeader._dkim_canonicalize_header(hval) hval = re.sub(rb'\s*', b'', self.hval) for chunk in hval.split(b';'): parts = chunk.split(b'=', 1) if len(parts) < 2: continue self.set_field(parts[0].decode(), parts[1]) def get_field(self, field: str, decode: bool = False) -> Union[None, str, bytes]: value = self.hdata.get(field) if isinstance(value, bytes) and decode: return value.decode() return value def set_field(self, field: str, value: Union[None, str, bytes]) -> None: if value is None: del self.hdata[field] return if isinstance(value, str): value = value.encode() self.hdata[field] = value # do any git-mailinfo normalization prior to calling this def set_body(self, body: bytes, maxlen: Optional[int] = None) -> None: if maxlen: if maxlen > len(body): raise ValidationError('maxlen is larger than payload') if maxlen < len(body): body = body[:maxlen] self.hdata['l'] = bytes(len(body)) hashed = hashlib.sha256() hashed.update(body) self._body_hash = base64.b64encode(hashed.digest()) # do any git-mailinfo normalization prior to calling this def set_headers(self, headers: list, mode: str) -> None: parsed = list() allhdrs = set() # DKIM operates on headers in reverse order for header in reversed(headers): try: left, right = header.split(b':', 1) hname = left.strip().lower() parsed.append((hname, right)) allhdrs.add(hname) except ValueError: continue reqset = set(REQ_HDRS) optset = set(OPT_HDRS) self._headervals = list() if mode == 'sign': # Make sure REQ_HDRS is a subset of allhdrs if not reqset.issubset(allhdrs): raise SigningError('The following required headers not present: %s' % (b', '.join(reqset.difference(allhdrs)).decode())) # Add optional headers that are actually present optpresent = list(allhdrs.intersection(optset)) signlist = REQ_HDRS + sorted(optpresent) self.hdata['h'] = b':'.join(signlist) elif mode == 'validate': hfield = self.get_field('h') signlist = [x.strip() for x in hfield.split(b':')] # Make sure REQ_HEADERS are in this set if not reqset.issubset(set(signlist)): raise ValidationError('The following required headers not signed: %s' % (b', '.join(reqset.difference(set(signlist))).decode())) else: raise RuntimeError('Unknown set_header mode: %s' % mode) for shname in signlist: if shname not in allhdrs: # Per RFC: # Nonexistent header fields do not contribute to the signature computation (that is, they are # treated as the null input, including the header field name, the separating colon, the header field # value, and any CRLF terminator). continue at = 0 for hname, rawval in list(parsed): if hname == shname: self._headervals.append(hname + b':' + DevsigHeader._dkim_canonicalize_header(rawval)) parsed.pop(at) break at += 1 def sanity_check(self) -> None: if 'a' not in self.hdata: raise RuntimeError('Must set "a" field first') if not self._body_hash: raise RuntimeError('Must use set_body first') if not self._headervals: raise RuntimeError('Must use set_headers first') def validate(self, keyinfo: Union[str, bytes, None]) -> Tuple[str, str]: self.sanity_check() # Start by validating the body hash. If it fails to match, we can # bail early, before needing to do any signature validation. if self.get_field('bh') != self._body_hash: raise BodyValidationError('Body content validation failed') # Check that we have a b= field if not self.get_field('b'): raise RuntimeError('Missing "b=" value') pts = self.hval.rsplit(b'b=', 1) dshdr = pts[0] + b'b=' bdata = re.sub(rb'\s*', b'', pts[1]) # Calculate our own digest hashed = hashlib.sha256() # Add in our _headervals first (they aready have CRLF endings) hashed.update(b''.join(self._headervals)) # and the devsig header now, without the trailing CRLF hashed.update(DEVSIG_HDR.lower() + b':' + dshdr) vdigest = hashed.digest() algo = self.get_field('a', decode=True) if algo.startswith('ed25519'): sdigest = DevsigHeader._validate_ed25519(bdata, keyinfo) signtime = self.get_field('t', decode=True) signkey = keyinfo if not signtime: raise ValidationError('t= field is required for ed25519 sigs') if sdigest != vdigest: raise ValidationError('Header validation failed') elif algo.startswith('openssh'): DevsigHeader._validate_openssh(bdata, vdigest, keyinfo) signtime = self.get_field('t', decode=True) signkey = keyinfo if not signtime: raise ValidationError('t= field is required for openssh sigs') elif algo.startswith('openpgp'): sdigest, (good, valid, trusted, signkey, signtime) = DevsigHeader._validate_openpgp(bdata, keyinfo) if sdigest != vdigest: raise ValidationError('Header validation failed') else: raise ValidationError('Unknown algorithm: %s', algo) return signkey, signtime def sign(self, keyinfo: Union[str, bytes], split: bool = True) -> Tuple[bytes, bytes]: self.sanity_check() self.set_field('bh', self._body_hash) algo = self.get_field('a', decode=True) hparts = list() for fn in self._order: fv = self.get_field(fn) if fv is not None: hparts.append(b'%s=%s' % (fn.encode(), fv)) hparts.append(b'b=') dshval = b'; '.join(hparts) hashed = hashlib.sha256() # Add in our _headervals first (they aready have CRLF endings) hashed.update(b''.join(self._headervals)) # and ourselves now, without the trailing CRLF hashed.update(DEVSIG_HDR.lower() + b':' + dshval) digest = hashed.digest() if algo.startswith('ed25519'): bval, pkinfo = DevsigHeader._sign_ed25519(digest, keyinfo) elif algo.startswith('openpgp'): bval, pkinfo = DevsigHeader._sign_openpgp(digest, keyinfo) elif algo.startswith('openssh'): bval, pkinfo = DevsigHeader._sign_openssh(digest, keyinfo) else: raise RuntimeError('Unknown a=%s' % algo) if split: return dshval + DevsigHeader.splitter(bval), pkinfo return dshval + bval, pkinfo @staticmethod def _sign_ed25519(payload: bytes, privkey: bytes) -> Tuple[bytes, bytes]: global KEYCACHE try: from nacl.signing import SigningKey from nacl.encoding import Base64Encoder except ModuleNotFoundError: raise RuntimeError('This operation requires PyNaCl libraries') if privkey not in KEYCACHE: sk = SigningKey(privkey, encoder=Base64Encoder) vk = base64.b64encode(sk.verify_key.encode()) KEYCACHE[privkey] = (sk, vk) else: sk, vk = KEYCACHE[privkey] bdata = sk.sign(payload, encoder=Base64Encoder) return bdata, vk @staticmethod def _validate_ed25519(sigdata: bytes, pubkey: bytes) -> bytes: try: from nacl.signing import VerifyKey from nacl.encoding import Base64Encoder from nacl.exceptions import BadSignatureError except ModuleNotFoundError: raise RuntimeError('This operation requires PyNaCl libraries') vk = VerifyKey(pubkey, encoder=Base64Encoder) try: return vk.verify(sigdata, encoder=Base64Encoder) except BadSignatureError: raise ValidationError('Failed to validate signature') @staticmethod def _sign_openssh(payload: bytes, keyfile: str) -> Tuple[bytes, bytes]: global KEYCACHE keypath = os.path.expanduser(os.path.expandvars(keyfile)) if not os.access(keypath, os.R_OK): raise SigningError('Unable to read openssh public key %s' % keypath) sshkargs = ['-Y', 'sign', '-n', 'patatt', '-f', keypath] ecode, out, err = sshk_run_command(sshkargs, payload) if ecode > 0: raise SigningError('Running ssh-keygen failed', errors=err.decode().split('\n')) # Remove the header/footer sigdata = b'' for bline in out.split(b'\n'): if bline.startswith(b'----'): continue sigdata += bline if keypath not in KEYCACHE: # Now get the fingerprint of this keyid sshkargs = ['-l', '-f', keypath] ecode, out, err = sshk_run_command(sshkargs, payload) if ecode > 0: raise SigningError('Running ssh-keygen failed', errors=err.decode().split('\n')) chunks = out.split() keyfp = chunks[1] KEYCACHE[keypath] = keyfp else: keyfp = KEYCACHE[keypath] return sigdata, keyfp @staticmethod def _validate_openssh(sigdata: bytes, payload: bytes, keydata: bytes) -> None: with tempfile.TemporaryDirectory(suffix='.patatt.ssh') as td: # Start by making a signers file fpath = os.path.join(td, 'signers') spath = os.path.join(td, 'sigdata') with open(fpath, 'wb') as fh: chunks = keydata.split() bcont = b'patatter@local namespaces="patatt" ' + chunks[0] + b' ' + chunks[1] + b'\n' logger.debug('allowed-signers: %s', bcont) fh.write(bcont) with open(spath, 'wb') as fh: bcont = b'-----BEGIN SSH SIGNATURE-----\n' + sigdata + b'\n-----END SSH SIGNATURE-----\n' logger.debug('sigdata: %s', bcont) fh.write(bcont) sshkargs = ['-Y', 'verify', '-n', 'patatt', '-I', 'patatter@local', '-f', fpath, '-s', spath] ecode, out, err = sshk_run_command(sshkargs, payload) if ecode > 0: raise ValidationError('Failed to validate openssh signature', errors=err.decode().split('\n')) @staticmethod def _sign_openpgp(payload: bytes, keyid: str) -> Tuple[bytes, bytes]: global KEYCACHE gpgargs = ['-s', '-u', keyid] ecode, out, err = gpg_run_command(gpgargs, payload) if ecode > 0: raise SigningError('Running gpg failed', errors=err.decode().split('\n')) bdata = base64.b64encode(out) # Now get the fingerprint of this keyid if keyid not in KEYCACHE: gpgargs = ['--with-colons', '--fingerprint', keyid] ecode, out, err = gpg_run_command(gpgargs) if ecode > 0: raise SigningError('Running gpg failed', errors=err.decode().split('\n')) pkid = None keyfp = None for line in out.split(b'\n'): if line.startswith(b'pub:'): fields = line.split(b':') pkid = fields[4] elif line.startswith(b'fpr:') and pkid: fields = line.split(b':') if fields[9].find(pkid) > 0: keyfp = fields[9] break KEYCACHE[keyid] = keyfp else: keyfp = KEYCACHE[keyid] return bdata, keyfp @staticmethod def _validate_openpgp(sigdata: bytes, pubkey: Optional[bytes]) -> Tuple[bytes, tuple]: global KEYCACHE bsigdata = base64.b64decode(sigdata) vrfyargs = ['--verify', '--output', '-', '--status-fd=2'] if pubkey: with tempfile.TemporaryDirectory(suffix='.patatt.gnupg') as td: keyringargs = ['--homedir', td, '--no-default-keyring', '--keyring', 'pub'] if pubkey in KEYCACHE: logger.debug('Reusing cached keyring') with open(os.path.join(td, 'pub'), 'wb') as kfh: kfh.write(KEYCACHE[pubkey]) else: logger.debug('Importing into new keyring') gpgargs = keyringargs + ['--status-fd=1', '--import'] ecode, out, err = gpg_run_command(gpgargs, stdin=pubkey) # look for IMPORT_OK if out.find(b'[GNUPG:] IMPORT_OK') < 0: raise ValidationError('Could not import GnuPG public key') with open(os.path.join(td, 'pub'), 'rb') as kfh: KEYCACHE[pubkey] = kfh.read() gpgargs = keyringargs + vrfyargs ecode, out, err = gpg_run_command(gpgargs, stdin=bsigdata) else: logger.debug('Verifying using default keyring') ecode, out, err = gpg_run_command(vrfyargs, stdin=bsigdata) if ecode > 0: if err.find(b'[GNUPG:] NO_PUBKEY '): raise NoKeyError('No matching key found') raise ValidationError('Failed to validate PGP signature') good, valid, trusted, signkey, signtime = DevsigHeader._check_gpg_status(err) if good and valid: return out, (good, valid, trusted, signkey, signtime) raise ValidationError('Failed to validate PGP signature') @staticmethod def _check_gpg_status(status: bytes) -> Tuple[bool, bool, bool, str, str]: good = False valid = False trusted = False signtime = '' signkey = '' logger.debug('GNUPG status:\n\t%s', status.decode().strip().replace('\n', '\n\t')) gs_matches = re.search(rb'^\[GNUPG:] GOODSIG ([0-9A-F]+)\s+(.*)$', status, flags=re.M) if gs_matches: good = True vs_matches = re.search(rb'^\[GNUPG:] VALIDSIG ([0-9A-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', status, flags=re.M) if vs_matches: valid = True signkey = vs_matches.groups()[0].decode() signtime = vs_matches.groups()[2].decode() ts_matches = re.search(rb'^\[GNUPG:] TRUST_(FULLY|ULTIMATE)', status, flags=re.M) if ts_matches: trusted = True return good, valid, trusted, signkey, signtime @staticmethod def splitter(longstr: bytes, limit: int = 75) -> bytes: splitstr = list() first = True while len(longstr) > limit: at = limit if first: first = False at -= 2 splitstr.append(longstr[:at]) longstr = longstr[at:] splitstr.append(longstr) return b' '.join(splitstr) @staticmethod def _dkim_canonicalize_header(hval: bytes) -> bytes: # Handle MIME encoded-word syntax or other types of header encoding if # present. The decode_header() function requires a str argument (not # bytes) so we must decode our bytes first, this is easy as RFC2822 (sec # 2.2) says header fields must be composed of US-ASCII characters. The # resulting string is re-encoded to allow further processing. if b'?q?' in hval: hval = hval.decode('ascii', errors='ignore') hval = str(email.header.make_header(email.header.decode_header(hval))) hval = hval.encode('utf-8') # We only do relaxed for headers # o Unfold all header field continuation lines as described in # [RFC5322]; in particular, lines with terminators embedded in # continued header field values (that is, CRLF sequences followed by # WSP) MUST be interpreted without the CRLF. Implementations MUST # NOT remove the CRLF at the end of the header field value. hval = re.sub(rb'[\r\n]', b'', hval) # o Convert all sequences of one or more WSP characters to a single SP # character. WSP characters here include those before and after a # line folding boundary. hval = re.sub(rb'\s+', b' ', hval) # o Delete all WSP characters at the end of each unfolded header field # value. # o Delete any WSP characters remaining before and after the colon # separating the header field name from the header field value. The # colon separator MUST be retained. hval = hval.strip() + b'\r\n' return hval class PatattMessage: def __init__(self, msgdata: bytes): self.headers = list() self.body = b'' self.lf = b'\n' self.signed = False self.canon_headers = None self.canon_body = None self.canon_identity = None self.sigs = None self.load_from_bytes(msgdata) def git_canonicalize(self): if self.canon_body is not None: return # Generate a new payload using m and p and canonicalize with \r\n endings, # trimming any excess blank lines ("simple" DKIM canonicalization). m, p, i = PatattMessage._get_git_mailinfo(b''.join(self.headers) + self.lf + self.body) self.canon_body = b'' for line in re.sub(rb'[\r\n]*$', b'', m + p).split(b'\n'): self.canon_body += re.sub(rb'[\r\n]*$', b'', line) + b'\r\n' idata = dict() for line in re.sub(rb'[\r\n]*$', b'', i).split(b'\n'): left, right = line.split(b':', 1) idata[left.lower()] = right.strip() # Theoretically, we should always see an "Email" line self.canon_identity = idata.get(b'email', b'').decode() # Now substituting headers returned by mailinfo self.canon_headers = list() for header in self.headers: try: left, right = header.split(b':', 1) lleft = left.lower() if lleft == b'from': right = b' ' + idata.get(b'author', b'') + b' <' + idata.get(b'email', b'') + b'>' elif lleft == b'subject': right = b' ' + idata.get(b'subject', b'') self.canon_headers.append(left + b':' + right) except ValueError: self.canon_headers.append(header) def sign(self, algo: str, keyinfo: Union[str, bytes], identity: Optional[str], selector: Optional[str]) -> None: # Remove any devsig headers for header in list(self.headers): if header.startswith(DEVSIG_HDR) or header.startswith(DEVKEY_HDR): self.headers.remove(header) self.git_canonicalize() ds = DevsigHeader() ds.set_headers(self.canon_headers, mode='sign') ds.set_body(self.canon_body) ds.set_field('l', str(len(self.canon_body))) if not identity: identity = self.canon_identity ds.set_field('i', identity) if selector: ds.set_field('s', selector) if algo not in ('ed25519', 'openpgp', 'openssh'): raise SigningError('Unsupported algorithm: %s' % algo) ds.set_field('a', '%s-sha256' % algo) if algo in ('ed25519', 'openssh'): # Set signing time for non-pgp sigs ds.set_field('t', str(int(time.time()))) hv, pkinfo = ds.sign(keyinfo) dshdr = email.header.make_header([(DEVSIG_HDR + b': ' + hv, 'us-ascii')], maxlinelen=78) self.headers.append(dshdr.encode().encode() + self.lf) # Make informational header about the key used idata = [ b'i=%s' % identity.encode(), b'a=%s' % algo.encode(), ] if algo == 'openpgp': idata.append(b'fpr=%s' % pkinfo) elif algo == 'openssh': idata.append(b'fpr=%s' % pkinfo) else: idata.append(b'pk=%s' % pkinfo) dkhdr = email.header.make_header([(DEVKEY_HDR + b': ' + b'; '.join(idata), 'us-ascii')], maxlinelen=78) self.headers.append(dkhdr.encode().encode() + self.lf) def validate(self, identity: str, pkey: Union[bytes, str, None], trim_body: bool = False) -> str: vds = None for ds in self.sigs: if ds.get_field('i', decode=True) == identity: vds = ds break if vds is None: raise ValidationError('No signatures matching identity %s' % identity) self.git_canonicalize() vds.set_headers(self.canon_headers, mode='validate') if trim_body: lfield = vds.get_field('l') if lfield: try: maxlen = int(lfield) vds.set_body(self.canon_body, maxlen=maxlen) except ValueError: vds.set_body(self.canon_body) else: vds.set_body(self.canon_body) return vds.validate(pkey) def as_bytes(self): return b''.join(self.headers) + self.lf + self.body def as_string(self, encoding='utf-8'): return self.as_bytes().decode(encoding) def load_from_bytes(self, msgdata: bytes) -> None: # We use simplest parsing -- using Python's email module would be overkill ldshn = DEVSIG_HDR.lower() with BytesIO(msgdata) as fh: while True: line = fh.readline() if not len(line): break if not len(line.strip()): self.lf = line self.body = fh.read() break # is it a wrapped header? if line[0] in ("\x09", "\x20", 0x09, 0x20): if not len(self.headers): raise RuntimeError('Not a valid RFC2822 message') # attach it to the previous header self.headers[-1] += line continue # Is it a signature header? if line.lower().startswith(ldshn): self.signed = True self.headers.append(line) if not len(self.headers) or not len(self.body): raise RuntimeError('Not a valid RFC2822 message') def get_sigs(self) -> list: if self.sigs is not None: return self.sigs ldshn = DEVSIG_HDR.lower() self.sigs = list() from_id = None for header in self.headers: try: left, right = header.split(b':', 1) hn = left.strip().lower() hv = right if hn == ldshn: self.sigs.append(DevsigHeader(hv)) elif hn == b'from': parts = email.utils.parseaddr(hv.decode().strip()) from_id = parts[1] except ValueError: raise RuntimeError('Error parsing headers') if from_id: for ds in self.sigs: if 'i' not in ds.hdata: ds.set_field('i', from_id) return self.sigs @staticmethod def _get_git_mailinfo(payload: bytes) -> Tuple[bytes, bytes, bytes]: with tempfile.TemporaryDirectory(suffix='.git-mailinfo') as td: mf = os.path.join(td, 'm') pf = os.path.join(td, 'p') cmdargs = ['git', 'mailinfo', '--encoding=utf-8', '--no-scissors', mf, pf] # normalize line endings in payload to single lf for consistent results # from git-mailinfo. ecode, i, err = _run_command(cmdargs, stdin=payload.replace(b'\r\n', b'\n')) if ecode > 0: logger.debug('FAILED : Failed running git-mailinfo:') logger.debug(err.decode()) raise RuntimeError('Failed to run git-mailinfo: %s' % err.decode()) with open(mf, 'rb') as mfh: m = mfh.read() with open(pf, 'rb') as pfh: p = pfh.read() return m, p, i def get_data_dir(): if 'XDG_DATA_HOME' in os.environ: datahome = os.environ['XDG_DATA_HOME'] else: datahome = os.path.join(str(Path.home()), '.local', 'share') datadir = os.path.join(datahome, 'patatt') Path(datadir).mkdir(parents=True, exist_ok=True) return datadir def _run_command(cmdargs: list, stdin: bytes = None, env: Optional[dict] = None) -> Tuple[int, bytes, bytes]: sp = subprocess.Popen(cmdargs, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE, env=env) logger.debug('Running %s', ' '.join(cmdargs)) (output, error) = sp.communicate(input=stdin) return sp.returncode, output, error def git_run_command(gitdir: Optional[str], args: list, stdin: Optional[bytes] = None, env: Optional[dict] = None) -> Tuple[int, bytes, bytes]: if gitdir: args = ['git', '--git-dir', gitdir, '--no-pager'] + args else: args = ['git', '--no-pager'] + args return _run_command(args, stdin=stdin, env=env) def get_config_from_git(regexp: str, section: Optional[str] = None, defaults: Optional[dict] = None, multivals: Optional[list] = None): if multivals is None: multivals = list() args = ['config', '-z', '--get-regexp', regexp] ecode, out, err = git_run_command(None, args) if defaults is None: defaults = dict() if not len(out): return defaults gitconfig = defaults out = out.decode() for line in out.split('\x00'): if not line: continue key, value = line.split('\n', 1) try: chunks = key.split('.') # Drop the starting part chunks.pop(0) cfgkey = chunks.pop(-1).lower() if len(chunks): if not section: # Ignore it continue # We're in a subsection sname = '.'.join(chunks) if sname != section: # Not our section continue elif section: # We want config from a subsection specifically continue if cfgkey in multivals: if cfgkey not in gitconfig: gitconfig[cfgkey] = list() gitconfig[cfgkey].append(value) else: gitconfig[cfgkey] = value except ValueError: logger.debug('Ignoring git config entry %s', line) return gitconfig def gpg_run_command(cmdargs: list, stdin: bytes = None) -> Tuple[int, bytes, bytes]: set_bin_paths(None) cmdargs = [GPGBIN, '--batch', '--no-auto-key-retrieve', '--no-auto-check-trustdb'] + cmdargs return _run_command(cmdargs, stdin) def sshk_run_command(cmdargs: list, stdin: bytes = None) -> Tuple[int, bytes, bytes]: set_bin_paths(None) cmdargs = [SSHKBIN] + cmdargs return _run_command(cmdargs, stdin) def get_git_toplevel(gitdir: str = None) -> str: cmdargs = ['git'] if gitdir: cmdargs += ['--git-dir', gitdir] cmdargs += ['rev-parse', '--show-toplevel'] ecode, out, err = _run_command(cmdargs) if ecode == 0: return out.decode().strip() return '' def make_pkey_path(keytype: str, identity: str, selector: str) -> str: chunks = identity.split('@', 1) if len(chunks) != 2: raise ValidationError('identity must include both local and domain parts') local = chunks[0].lower() domain = chunks[1].lower() selector = selector.lower() # urlencode all potentially untrusted bits to make sure nobody tries path-based badness keypath = os.path.join(urllib.parse.quote_plus(keytype), urllib.parse.quote_plus(domain), urllib.parse.quote_plus(local), urllib.parse.quote_plus(selector)) return keypath def get_public_key(source: str, keytype: str, identity: str, selector: str) -> Tuple[bytes, str]: keypath = make_pkey_path(keytype, identity, selector) logger.debug('Looking for %s in %s', keypath, source) # ref:refs/heads/someref:in-repo/path if source.startswith('ref:'): # split by : parts = source.split(':', 4) if len(parts) < 4: raise ConfigurationError('Invalid ref, must have at least 3 colons: %s' % source) gitrepo = parts[1] gitref = parts[2] gitsub = parts[3] if not gitrepo: gitrepo = get_git_toplevel() if not gitrepo: raise KeyError('Not in a git tree, so cannot use a ref:: source') gitrepo = os.path.expanduser(gitrepo) if gitrepo.find('$') >= 0: gitrepo = os.path.expandvars(gitrepo) if os.path.isdir(os.path.join(gitrepo, '.git')): gittop = os.path.join(gitrepo, '.git') else: gittop = gitrepo # it could omit the refspec, meaning "whatever the current ref" # grab the key from a fully ref'ed path subpath = os.path.join(gitsub, keypath) if not gitref: # What is our current ref? cmdargs = ['symbolic-ref', 'HEAD'] ecode, out, err = git_run_command(gittop, cmdargs) if ecode == 0: gitref = out.decode().strip() if not gitref: raise KeyError('Could not figure out current ref in %s' % gittop) keysrc = f'{gitref}:{subpath}' cmdargs = ['show', keysrc] ecode, out, err = git_run_command(gittop, cmdargs) if ecode == 0: # Handle one level of symlinks if out.find(b'\n') < 0 < out.find(b'/'): # Check this path as well linktgt = os.path.normpath(os.path.join(os.path.dirname(subpath), out.decode())) keysrc = f'{gitref}:{linktgt}' cmdargs = ['show', keysrc] ecode, out, err = git_run_command(gittop, cmdargs) if ecode == 0: logger.debug('KEYSRC : %s (symlinked)', keysrc) return out, 'ref:%s:%s' % (gittop, keysrc) logger.debug('KEYSRC : %s', keysrc) return out, 'ref:%s:%s' % (gittop, keysrc) # Does it exist on disk but hasn't been committed yet? fullpath = os.path.join(gitrepo, subpath) if os.path.exists(fullpath): with open(fullpath, 'rb') as fh: logger.debug('KEYSRC : %s', fullpath) return fh.read(), fullpath raise KeyError('Could not find %s in %s:%s' % (subpath, gittop, gitref)) # It's a disk path, then # Expand ~ and env vars source = os.path.expanduser(source) if source.find('$') >= 0: source = os.path.expandvars(source) fullpath = os.path.join(source, keypath) if os.path.exists(fullpath): with open(fullpath, 'rb') as fh: logger.debug('Loaded key from %s', fullpath) return fh.read(), fullpath raise KeyError('Could not find %s' % fullpath) def _load_messages(cmdargs) -> dict: import sys if len(cmdargs.msgfile): # Load all message from the files passed to make sure they all parse correctly messages = dict() for msgfile in cmdargs.msgfile: with open(msgfile, 'rb') as fh: messages[msgfile] = fh.read() elif not sys.stdin.isatty(): messages = {'-': sys.stdin.buffer.read()} else: logger.critical('E: Pipe a message to sign or pass filenames with individual messages') raise RuntimeError('Nothing to do') return messages def sign_message(msgdata: bytes, algo: str, keyinfo: Union[str, bytes], identity: Optional[str], selector: Optional[str]) -> bytes: pm = PatattMessage(msgdata) pm.sign(algo, keyinfo, identity=identity, selector=selector) return pm.as_bytes() def set_bin_paths(config: Optional[dict]) -> None: global GPGBIN, SSHKBIN if GPGBIN is None: gpgcfg = get_config_from_git(r'gpg\..*') if config and config.get('gpg-bin'): GPGBIN = config.get('gpg-bin') elif gpgcfg.get('program'): GPGBIN = gpgcfg.get('program') else: GPGBIN = 'gpg' if SSHKBIN is None: sshcfg = get_config_from_git(r'gpg\..*', section='ssh') if config and config.get('ssh-keygen-bin'): SSHKBIN = config.get('ssh-keygen-bin') elif sshcfg.get('program'): SSHKBIN = sshcfg.get('program') else: SSHKBIN = 'ssh-keygen' def get_algo_keydata(config: dict) -> Tuple[str, str]: global KEYCACHE # Do we have the signingkey defined? usercfg = get_config_from_git(r'user\..*') if not config.get('identity') and usercfg.get('email'): # Use user.email config['identity'] = usercfg.get('email') # Do we have this already looked up? if config['identity'] in KEYCACHE: return KEYCACHE[config['identity']] if not config.get('signingkey'): if usercfg.get('signingkey'): logger.info('N: Using pgp key %s defined by user.signingkey', usercfg.get('signingkey')) logger.info('N: Override by setting patatt.signingkey') config['signingkey'] = 'openpgp:%s' % usercfg.get('signingkey') else: logger.critical('E: patatt.signingkey is not set') logger.critical('E: Perhaps you need to run genkey first?') raise NoKeyError('patatt.signingkey is not set') sk = config.get('signingkey') if sk.startswith('ed25519:'): algo = 'ed25519' identifier = sk[8:] keysrc = None if identifier.startswith('/') and os.path.exists(identifier): keysrc = identifier else: # datadir/private/%s.key ddir = get_data_dir() skey = os.path.join(ddir, 'private', '%s.key' % identifier) if os.path.exists(skey): keysrc = skey else: # finally, try .git/%s.key gtdir = get_git_toplevel() if gtdir: skey = os.path.join(gtdir, '.git', '%s.key' % identifier) if os.path.exists(skey): keysrc = skey if not keysrc: raise ConfigurationError('Could not find the key matching %s' % identifier) logger.info('N: Using ed25519 key: %s', keysrc) with open(keysrc, 'r') as fh: keydata = fh.read() elif sk.startswith('openpgp:'): algo = 'openpgp' keydata = sk[8:] elif sk.startswith('openssh:'): algo = 'openssh' keydata = sk[8:] else: logger.critical('E: Unknown key type: %s', sk) raise ConfigurationError('Unknown key type: %s' % sk) KEYCACHE[config['identity']] = (algo, keydata) return algo, keydata def rfc2822_sign(message: bytes, config: Optional[dict] = None) -> bytes: if config is None: config = get_main_config() algo, keydata = get_algo_keydata(config) pm = PatattMessage(message) pm.sign(algo, keydata, identity=config.get('identity'), selector=config.get('selector')) logger.debug('--- SIGNED MESSAGE STARTS ---') logger.debug(pm.as_string()) return pm.as_bytes() def get_main_config(section: Optional[str] = None) -> dict: global CONFIGCACHE if section in CONFIGCACHE: return CONFIGCACHE[section] config = get_config_from_git(r'patatt\..*', section=section, multivals=['keyringsrc']) # Append some extra keyring locations if 'keyringsrc' not in config: config['keyringsrc'] = list() config['keyringsrc'] += ['ref:::.keys', 'ref:::.local-keys', 'ref::refs/meta/keyring:'] set_bin_paths(config) logger.debug('config: %s', config) CONFIGCACHE[section] = config return config def cmd_sign(cmdargs, config: dict) -> None: try: messages = _load_messages(cmdargs) except IOError as ex: logger.critical('E: %s', ex) sys.exit(1) for fn, msgdata in messages.items(): try: signed = rfc2822_sign(msgdata, config) logger.debug('--- SIGNED MESSAGE STARTS ---') logger.debug(signed.decode()) if fn == '-': sys.stdout.buffer.write(signed) else: with open(fn, 'wb') as fh: fh.write(signed) logger.critical('SIGN | %s', os.path.basename(fn)) except (ConfigurationError, NoKeyError) as ex: logger.debug('Exiting due to %s', ex) sys.exit(1) except SigningError as ex: logger.critical('E: %s', ex) sys.exit(1) except RuntimeError as ex: logger.critical('E: %s: %s' % (fn, ex)) sys.exit(1) def validate_message(msgdata: bytes, sources: list, trim_body: bool = False) -> list: attestations = list() pm = PatattMessage(msgdata) if not pm.signed: logger.debug('message is not signed') attestations.append((RES_NOSIG, None, None, None, None, ['no signatures found'])) return attestations # Find all identities for which we have public keys for ds in pm.get_sigs(): errors = list() a = ds.get_field('a', decode=True) i = ds.get_field('i', decode=True) s = ds.get_field('s', decode=True) t = ds.get_field('t', decode=True) if not s: s = 'default' if a.startswith('ed25519'): algo = 'ed25519' elif a.startswith('openpgp'): algo = 'openpgp' elif a.startswith('openssh'): algo = 'openssh' else: errors.append('%s/%s Unknown algorigthm: %s' % (i, s, a)) attestations.append((RES_ERROR, i, t, None, a, errors)) continue pkey = keysrc = None for source in sources: try: pkey, keysrc = get_public_key(source, algo, i, s) break except KeyError: pass if not pkey and algo in ('ed25519', 'openssh'): errors.append('%s/%s no matching %s key found' % (i, s, algo)) attestations.append((RES_NOKEY, i, t, None, algo, errors)) continue try: signkey, signtime = pm.validate(i, pkey, trim_body=trim_body) if keysrc is None: # Default keyring used keysrc = '(default keyring)/%s' % signkey attestations.append((RES_VALID, i, signtime, keysrc, algo, errors)) except NoKeyError: # Not in default keyring errors.append('%s/%s no matching openpgp key found' % (i, s)) attestations.append((RES_NOKEY, i, t, None, algo, errors)) except ValidationError: if keysrc is None: errors.append('failed to validate using default keyring') else: errors.append('failed to validate using %s' % keysrc) attestations.append((RES_BADSIG, i, t, keysrc, algo, errors)) return attestations def cmd_validate(cmdargs, config: dict): import mailbox if len(cmdargs.msgfile) == 1: # Try to open as an mbox file try: mbox = mailbox.mbox(cmdargs.msgfile[0]) except IOError as ex: logger.critical('E: %s', ex) sys.exit(1) messages = dict() for msg in mbox: subject = msg.get('Subject', 'No subject') messages[subject] = msg.as_bytes() else: try: messages = _load_messages(cmdargs) except IOError as ex: logger.critical('E: %s', ex) sys.exit(1) ddir = get_data_dir() pdir = os.path.join(ddir, 'public') sources = config.get('keyringsrc') if pdir not in sources: sources.append(pdir) if config.get('trimbody', 'no') == 'yes': trim_body = True else: trim_body = False highest_err = 0 for fn, msgdata in messages.items(): try: attestations = validate_message(msgdata, sources, trim_body=trim_body) for result, identity, signtime, keysrc, algo, errors in attestations: if result > highest_err: highest_err = result if result == RES_VALID: logger.critical(' PASS | %s, %s', identity, fn) if keysrc: logger.info(' | key: %s', keysrc) else: logger.info(' | key: default GnuPG keyring') elif result <= RES_NOSIG: logger.critical(' NOSIG | %s', fn) for error in errors: logger.critical(' | %s', error) elif result <= RES_NOKEY: logger.critical(' NOKEY | %s, %s', identity, fn) for error in errors: logger.critical(' | %s', error) elif result <= RES_ERROR: logger.critical(' ERROR | %s, %s', identity, fn) for error in errors: logger.critical(' | %s', error) else: logger.critical('BADSIG | %s, %s', identity, fn) for error in errors: logger.critical(' | %s', error) except RuntimeError as ex: highest_err = RES_ERROR logger.critical(' ERROR | err: %s | %s', ex, fn) sys.exit(highest_err) def cmd_genkey(cmdargs, config: dict) -> None: try: from nacl.signing import SigningKey except ModuleNotFoundError: raise RuntimeError('This operation requires PyNaCl libraries') # Do we have the signingkey defined? usercfg = get_config_from_git(r'user\..*') if not config.get('identity'): if not usercfg.get('email'): logger.critical('This operation requires user.email to be set') sys.exit(1) # Use user.email config['identity'] = usercfg.get('email') identifier = cmdargs.keyname if not identifier: identifier = datetime.datetime.today().strftime('%Y%m%d') ddir = get_data_dir() sdir = os.path.join(ddir, 'private') pdir = os.path.join(ddir, 'public') if not os.path.exists(sdir): os.mkdir(sdir, mode=0o0700) if not os.path.exists(pdir): os.mkdir(pdir, mode=0o0755) skey = os.path.join(sdir, '%s.key' % identifier) pkey = os.path.join(pdir, '%s.pub' % identifier) # Do we have a key with this identifier already present? if os.path.exists(skey) and not cmdargs.force: logger.critical('Key already exists: %s', skey) logger.critical('Use a different -n or pass -f to overwrite it') raise RuntimeError('Key already exists') logger.critical('Generating a new ed25519 keypair') newkey = SigningKey.generate() # Make sure we write it as 0600 def priv_opener(path, flags): return os.open(path, flags, 0o0600) with open(skey, 'wb', opener=priv_opener) as fh: fh.write(base64.b64encode(bytes(newkey))) logger.critical('Wrote: %s', skey) with open(pkey, 'wb') as fh: fh.write(base64.b64encode(newkey.verify_key.encode())) logger.critical('Wrote: %s', pkey) # Also copy it into our local keyring spkey = os.path.join(pdir, make_pkey_path('ed25519', config.get('identity'), identifier)) Path(os.path.dirname(spkey)).mkdir(parents=True, exist_ok=True) with open(spkey, 'wb') as fh: fh.write(base64.b64encode(newkey.verify_key.encode())) logger.critical('Wrote: %s', spkey) dpkey = os.path.join(pdir, make_pkey_path('ed25519', config.get('identity'), 'default')) if not os.path.exists(dpkey): # symlink our new key to be the default os.symlink(identifier, dpkey) logger.critical('Add the following to your .git/config (or global ~/.gitconfig):') logger.critical('---') if cmdargs.section: logger.critical('[patatt "%s"]', cmdargs.section) else: logger.critical('[patatt]') logger.critical(' signingkey = ed25519:%s', identifier) logger.critical(' selector = %s', identifier) logger.critical('---') logger.critical('Next, communicate the contents of the following file to the') logger.critical('repository keyring maintainers for inclusion into the project:') logger.critical(pkey) def cmd_install_hook(cmdargs, config: dict): # noqa gitrepo = get_git_toplevel() if not gitrepo: logger.critical('Not in a git tree, cannot install hook') sys.exit(1) hookfile = os.path.join(gitrepo, '.git', 'hooks', 'sendemail-validate') if os.path.exists(hookfile): logger.critical('Hook already exists: %s', hookfile) sys.exit(1) Path(os.path.join(gitrepo, '.git', 'hooks')).mkdir(parents=True, exist_ok=True) with open(hookfile, 'w') as fh: fh.write('#!/bin/sh\n') fh.write('# installed by patatt install-hook\n') fh.write('patatt sign --hook "${1}"\n') os.chmod(hookfile, 0o755) logger.critical('Hook installed as %s', hookfile) def command() -> None: import argparse # noinspection PyTypeChecker parser = argparse.ArgumentParser( prog='patatt', description='Cryptographically attest patches before sending out', formatter_class=argparse.ArgumentDefaultsHelpFormatter ) parser.add_argument('-v', '--verbose', action='store_true', default=False, help='Be a bit more verbose') parser.add_argument('-d', '--debug', action='store_true', default=False, help='Show debugging output') parser.add_argument('-s', '--section', dest='section', default=None, help='Use config section [patatt "sectionname"]') parser.add_argument('--version', action='version', version=__VERSION__) subparsers = parser.add_subparsers(help='sub-command help', dest='subcmd') sp_sign = subparsers.add_parser('sign', help='Cryptographically attest an RFC2822 message') sp_sign.add_argument('--hook', dest='hookmode', action='store_true', default=False, help='Git hook mode') sp_sign.add_argument('msgfile', nargs='*', help='RFC2822 message files to sign') sp_sign.set_defaults(func=cmd_sign) sp_val = subparsers.add_parser('validate', help='Validate a devsig-signed message') sp_val.add_argument('msgfile', nargs='*', help='Individual signed message files to validate or an mbox') sp_val.set_defaults(func=cmd_validate) sp_gen = subparsers.add_parser('genkey', help='Generate a new ed25519 keypair') sp_gen.add_argument('-n', '--keyname', default=None, help='Name to use for the key, e.g. "workstation", or "default"') sp_gen.add_argument('-f', '--force', action='store_true', default=False, help='Overwrite any existing keys, if found') sp_gen.set_defaults(func=cmd_genkey) sp_install = subparsers.add_parser('install-hook', help='Install sendmail-validate hook into the current repo') sp_install.set_defaults(func=cmd_install_hook) _args = parser.parse_args() logger.setLevel(logging.DEBUG) ch = logging.StreamHandler() formatter = logging.Formatter('%(message)s') try: if _args.hookmode: formatter = logging.Formatter('patatt: %(message)s') except AttributeError: pass ch.setFormatter(formatter) if _args.verbose: ch.setLevel(logging.INFO) elif _args.debug: ch.setLevel(logging.DEBUG) else: ch.setLevel(logging.CRITICAL) logger.addHandler(ch) config = get_main_config(section=_args.section) if 'func' not in _args: parser.print_help() sys.exit(1) try: _args.func(_args, config) except RuntimeError: sys.exit(1) if __name__ == '__main__': command() b4-0.13.0/patatt/requirements.txt000066400000000000000000000000071436430550600166640ustar00rootroot00000000000000PyNaCl b4-0.13.0/patatt/samples/000077500000000000000000000000001436430550600150475ustar00rootroot00000000000000b4-0.13.0/patatt/samples/ed25519-signed.txt000066400000000000000000000017501436430550600200600ustar00rootroot00000000000000From 82d3e4a03a72b787849fd406e985f3027fa04907 Mon Sep 17 00:00:00 2001 From: Konstantin Ryabitsev Date: Wed, 5 May 2021 17:11:46 -0400 Subject: [PATCH] Specify subset of the world X-Developer-Signature: v=1; a=ed25519-sha256; t=1620249230; l=403; i=mricon@kernel.org; s=20210505; h=from:subject; bh=aWNA6NFmS5xpRH5Gpy45nWiKCOnDOKHOYOV7Y6lyLcU=; b=6eJfjTMpYzbUgSeNZf3OqQDjzFVooz6WfaEitTMIpYOWLCXRU8qCR3cjUTmLET5S8prJVMypxnZl C1/hsoX8DWfyZmyquQSjCCNv2ISvZ8vEKCjXt2g0xmgK+XstajvB X-Developer-Key: i=mricon@kernel.org; a=ed25519; pk=i+0Am6o59VU+dAfK4WhkCl56BrA+rY4cXlq3AbO5M8c= We don't want to say hello to the *whole* world, do we? Just the attested world, please. Signed-off-by: Konstantin Ryabitsev --- hello.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hello.txt b/hello.txt index 18249f3..977f79b 100644 --- a/hello.txt +++ b/hello.txt @@ -1 +1 @@ -Hello world. +Hello attested world. -- 2.30.2 b4-0.13.0/patatt/samples/openssh-signed.txt000066400000000000000000000021711436430550600205370ustar00rootroot00000000000000From 82d3e4a03a72b787849fd406e985f3027fa04907 Mon Sep 17 00:00:00 2001 From: Konstantin Ryabitsev Date: Wed, 5 May 2021 17:11:46 -0400 Subject: [PATCH] Specify subset of the world X-Developer-Signature: v=1; a=openssh-sha256; t=1636987789; l=403; i=konstantin@linuxfoundation.org; s=20211115; h=from:subject; bh=aWNA6NFmS5xpRH5Gpy45nWiKCOnDOKHOYOV7Y6lyLcU=; b=U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgqCBwATTti8v9QsWJB4x1yVA72ozVqlXw jcZ/ImRjPZsAAAAGcGF0YXR0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5AAAAQGyoMN fuL86rhp2CLqjzAoVC9l1sFREfyvnkT/6QpnYht/gQCkAp+KyvWLOaywWPekG5OGMbmwnMu4WOSKmI 0Qo= X-Developer-Key: i=konstantin@linuxfoundation.org; a=openssh; fpr=SHA256:movubj27MLZcp0EAsOhlbu3/RJkj1VF9FfHGUsiB4Gw We don't want to say hello to the *whole* world, do we? Just the attested world, please. Signed-off-by: Konstantin Ryabitsev --- hello.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hello.txt b/hello.txt index 18249f3..977f79b 100644 --- a/hello.txt +++ b/hello.txt @@ -1 +1 @@ -Hello world. +Hello attested world. -- 2.30.2 b4-0.13.0/patatt/samples/pgp-signed.txt000066400000000000000000000020501436430550600176420ustar00rootroot00000000000000From 82d3e4a03a72b787849fd406e985f3027fa04907 Mon Sep 17 00:00:00 2001 From: Konstantin Ryabitsev Date: Wed, 5 May 2021 17:11:46 -0400 Subject: [PATCH] Specify subset of the world X-Developer-Signature: v=1; a=openpgp-sha256; l=403; h=from:subject; bh=aWNA6NFmS5xpRH5Gpy45nWiKCOnDOKHOYOV7Y6lyLcU=; b=owGbwMvMwCG27YjM47CUmTmMp9WSGBImc6WkxnPMSdRqXmTvUr/vDJNAxRutvTblG+aIdndskFKK 9zDtKGVhEONgkBVTZCnbF7spqPChh1x6jynMHFYmkCEMXJwCMJH+Lob/MStfTr7/tPydwKmcytxN82 0ObFx4Uf6ftazRnu0sKpsc3jH893rSXHbuxoXrL0SkeeN/75lzb/u39zrtrNMU9gVbqZfqcAEA X-Developer-Key: i=konstantin@linuxfoundation.org; a=openpgp; fpr=DE0E66E32F1FDD0902666B96E63EDCA9329DD07E We don't want to say hello to the *whole* world, do we? Just the attested world, please. Signed-off-by: Konstantin Ryabitsev --- hello.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hello.txt b/hello.txt index 18249f3..977f79b 100644 --- a/hello.txt +++ b/hello.txt @@ -1 +1 @@ -Hello world. +Hello attested world. -- 2.30.2 b4-0.13.0/patatt/samples/unsigned.txt000066400000000000000000000011311436430550600174200ustar00rootroot00000000000000From 82d3e4a03a72b787849fd406e985f3027fa04907 Mon Sep 17 00:00:00 2001 From: Konstantin Ryabitsev Date: Wed, 5 May 2021 17:11:46 -0400 Subject: [PATCH] Specify subset of the world We don't want to say hello to the *whole* world, do we? Just the attested world, please. Signed-off-by: Konstantin Ryabitsev --- hello.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hello.txt b/hello.txt index 18249f3..977f79b 100644 --- a/hello.txt +++ b/hello.txt @@ -1 +1 @@ -Hello world. +Hello attested world. -- 2.30.2 b4-0.13.0/patatt/sendemail-validate-hook000077500000000000000000000006721436430550600200240ustar00rootroot00000000000000#!/usr/bin/env bash if which patatt>/dev/null 2>&1; then # We have it in path, so just execute it patatt sign --hook "${1}" else # Assume we're symlinked into a git checkout REAL_SCRIPT=$(realpath -e ${BASH_SOURCE[0]}) SCRIPT_TOP="${SCRIPT_TOP:-$(dirname ${REAL_SCRIPT})}" PATATT_TOP=$(realpath -e ${SCRIPT_TOP}) exec env PYTHONPATH="${PATATT_TOP}" python3 "${PATATT_TOP}/patatt/__init__.py" sign --hook "${1}" fi b4-0.13.0/patatt/setup.py000066400000000000000000000025751436430550600151260ustar00rootroot00000000000000#!/usr/bin/env python3 import os import re from setuptools import setup # Utility function to read the README file. # Used for the long_description. It's nice, because now 1) we have a top level # README file and 2) it's easier to type in the README file than to put a raw # string in below ... def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() def find_version(source): version_file = read(source) version_match = re.search(r"^__VERSION__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) raise RuntimeError("Unable to find version string.") NAME = 'patatt' setup( version=find_version('patatt/__init__.py'), url='https://git.kernel.org/pub/scm/utils/patatt/patatt.git/about/', name=NAME, description='A simple library to add cryptographic attestation to patches sent via email', author='Konstantin Ryabitsev', author_email='mricon@kernel.org', packages=['patatt'], license='MIT-0', long_description=read('README.rst'), long_description_content_type='text/x-rst', data_files = [('share/man/man5', ['man/patatt.5'])], keywords=['git', 'patches', 'attestation'], install_requires=[ 'pynacl', ], python_requires='>=3.6', entry_points={ 'console_scripts': [ 'patatt=patatt:command' ], }, )