pax_global_header00006660000000000000000000000064141136761050014516gustar00rootroot0000000000000052 comment=d8937ede7064a74623a9d1ef260d5d50a146dd44 b4-0.8.0/000077500000000000000000000000001411367610500120305ustar00rootroot00000000000000b4-0.8.0/.gitignore000066400000000000000000000002141411367610500140150ustar00rootroot00000000000000*.swp *.pyc *.pyo *.json *.pdf test.log build/* dist/* MANIFEST .idea __pycache__ *.egg-info *.patch *.mbx *.maildir *.cover *.thanks .venv b4-0.8.0/.gitmodules000066400000000000000000000001421411367610500142020ustar00rootroot00000000000000[submodule "patatt"] path = patatt url = https://git.kernel.org/pub/scm/utils/patatt/patatt.git b4-0.8.0/.keys/000077500000000000000000000000001411367610500130615ustar00rootroot00000000000000b4-0.8.0/.keys/openpgp/000077500000000000000000000000001411367610500145315ustar00rootroot00000000000000b4-0.8.0/.keys/openpgp/linuxfoundation.org/000077500000000000000000000000001411367610500205455ustar00rootroot00000000000000b4-0.8.0/.keys/openpgp/linuxfoundation.org/konstantin/000077500000000000000000000000001411367610500227355ustar00rootroot00000000000000b4-0.8.0/.keys/openpgp/linuxfoundation.org/konstantin/default000066400000000000000000000561461411367610500243200ustar00rootroot00000000000000-----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.8.0/.keys/openpgp/pbarker.dev/000077500000000000000000000000001411367610500167345ustar00rootroot00000000000000b4-0.8.0/.keys/openpgp/pbarker.dev/paul/000077500000000000000000000000001411367610500176755ustar00rootroot00000000000000b4-0.8.0/.keys/openpgp/pbarker.dev/paul/default000066400000000000000000000066601411367610500212540ustar00rootroot00000000000000-----BEGIN PGP PUBLIC KEY BLOCK----- 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 =907n -----END PGP PUBLIC KEY BLOCK----- b4-0.8.0/COPYING000066400000000000000000000432541411367610500130730ustar00rootroot00000000000000 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.8.0/MANIFEST.in000066400000000000000000000000641411367610500135660ustar00rootroot00000000000000include COPYING include man/*.rst include *.example b4-0.8.0/README.rst000066400000000000000000000050301411367610500135150ustar00rootroot00000000000000B4 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 man/b4.5.rst for more information. 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 Patch attestation (EXPERIMENTAL) -------------------------------- B4 implements two attestation verification mechanisms: - DKIM attestation using the dkimpy library - X-Developer-Signature attestation using the patatt library If you installed from pip, you should have pulled both of these dependencies in automatically. Alternatively, you can install dkimpy from your OS packaging and then run "git submodule update --init" to clone patatt as a submodule of b4. For attesting your outgoing patches, see patatt documentation. https://git.kernel.org/pub/scm/utils/patatt/patatt.git/about/ Display attestation results on received patches ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ There are four attestation verification modes in b4: - off - check - softfail (default) - hardfail The "check" policy is look for any available attestation and try to verify it. If verification fails, b4 will not output any errors, but will not show verification checkmarks either. In the "softfail" mode, any verification errors will be prominently displayed, but b4 will still produce the resulting file with patches. The "hardfail" mode will show verification errors and exit without generating the .mbx file with patches. You can set the preferred policy via the git configuration file:: [b4] attestation-policy = hardfail Support ------- For support or with any other questions, please email tools@linux.kernel.org, or browse the list archive at https://lore.kernel.org/tools. b4-0.8.0/b4.sh000077500000000000000000000004011411367610500126670ustar00rootroot00000000000000#!/usr/bin/env bash # # Run b4 from a git checkout. # REAL_SCRIPT=$(realpath -e ${BASH_SOURCE[0]}) SCRIPT_TOP="${SCRIPT_TOP:-$(dirname ${REAL_SCRIPT})}" exec env PYTHONPATH="${SCRIPT_TOP}:${SCRIPT_TOP}/patatt" python3 "${SCRIPT_TOP}/b4/command.py" "${@}" b4-0.8.0/b4/000077500000000000000000000000001411367610500123355ustar00rootroot00000000000000b4-0.8.0/b4/__init__.py000066400000000000000000002677561411367610500144750ustar00rootroot00000000000000# 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 tempfile import pathlib import requests import urllib.parse import datetime import time import copy import shutil import mailbox # noinspection PyCompatibility import pwd from contextlib import contextmanager from typing import Optional, Tuple, Set, List, TextIO from email import charset charset.add_charset('utf-8', None) emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length=None) try: import dkim can_dkim = True except ModuleNotFoundError: can_dkim = False try: import patatt can_patatt = True except ModuleNotFoundError: can_patatt = False __VERSION__ = '0.8.0' 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+)') ATT_PASS_SIMPLE = 'v' ATT_FAIL_SIMPLE = 'x' ATT_PASS_FANCY = '\033[32m\u2713\033[0m' ATT_FAIL_FANCY = '\033[31m\u2717\033[0m' DEVSIG_HDR = 'X-Developer-Signature' # Headers to include into am-ready messages # From: and Subject: are always included AMHDRS = [ 'Date', 'Message-Id', 'To', 'Cc', 'Reply-To', 'In-Reply-To', 'References', 'List-Id', ] # You can use bash-style globbing here # end with '*' to include any other trailers # You can change the default in your ~/.gitconfig, e.g.: # [b4] # # remember to end with ,* # trailer-order=link*,fixes*,cc*,reported*,suggested*,original*,co-*,tested*,reviewed*,acked*,signed-off*,* # (another common) # trailer-order=fixes*,reported*,suggested*,original*,co-*,signed-off*,tested*,reviewed*,acked*,cc*,link*,* # # Or use _preserve_ (alias to *) to keep the order unchanged DEFAULT_TRAILER_ORDER = '*' LOREADDR = 'https://lore.kernel.org' DEFAULT_CONFIG = { 'midmask': LOREADDR + '/r/%s', 'linkmask': LOREADDR + '/r/%s', 'trailer-order': DEFAULT_TRAILER_ORDER, '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, } # This is where we store actual config MAIN_CONFIG = None # This is git-config user.* USER_CONFIG = None # Used for storing our requests session REQSESSION = None # Indicates that we've cleaned cache already _CACHE_CLEANED = False class LoreMailbox: msgid_map: dict series: dict covers: dict followups: list unknowns: list 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): 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 get_series(self, revision=None, sloppytrailers=False, reroll=True): 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 # 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 refs = fmsg.msg.get('References', '') pmsg = None for ref in refs.split(): refid = ref.strip('<>') 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 trailer in mismatches: lser.trailer_mismatches.add((trailer[0], trailer[1], fmsg.fromname, fmsg.fromemail)) lvl = 1 while True: logger.debug('%sParent: %s', ' ' * lvl, pmsg.full_subject) logger.debug('%sTrailers:', ' ' * lvl) for trailer in trailers: logger.debug('%s%s: %s', ' ' * (lvl+1), trailer[0], trailer[1]) 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.pwhash: if pmsg.pwhash not in self.trailer_map: self.trailer_map[pmsg.pwhash] = list() self.trailer_map[pmsg.pwhash] += 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: lvl += 1 for ptrailer in pmsg.trailers: trailers.append(tuple(ptrailer + [pmsg])) 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.pwhash is None: continue if lmsg.pwhash in self.trailer_map: lmsg.followup_trailers += self.trailer_map[lmsg.pwhash] return lser def add_message(self, msg): msgid = LoreMessage.get_clean_msgid(msg) if msgid in self.msgid_map: logger.debug('Already have a message with this msgid, skipping %s', msgid) return lmsg = LoreMessage(msg) logger.debug('Looking at: %s', lmsg.full_subject) self.msgid_map[lmsg.msgid] = lmsg if 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: def __init__(self, revision, expected): self.revision = revision self.expected = expected self.patches = [None] * (expected+1) self.followups = list() self.trailer_mismatches = set() self.complete = False self.has_cover = False self.partial_reroll = False self.subject = '(untitled)' # Used for base matching self._indexes = None def __repr__(self): out = list() 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(' 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): 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] if omsg.reply or (omsg.counters_inferred and not lmsg.counters_inferred): # Replace that one with this one logger.debug(' replacing existing: %s', omsg.subject) self.patches[lmsg.counter] = lmsg else: self.patches[lmsg.counter] = lmsg self.complete = not (None in self.patches[1:]) if self.patches[0] is not None: # noinspection PyUnresolvedReferences self.subject = self.patches[0].subject elif self.patches[1] is not None: # noinspection PyUnresolvedReferences self.subject = self.patches[1].subject def get_slug(self, extended=False): # 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 get_am_ready(self, noaddtrailers=False, covertrailers=False, trailer_order=None, addmysob=False, addlink=False, linkmask=None, cherrypick=None, copyccs=False) -> list: 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 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: if self.has_cover and covertrailers and self.patches[0].followup_trailers: # noqa lmsg.followup_trailers += self.patches[0].followup_trailers # noqa if addmysob: lmsg.followup_trailers.append(('Signed-off-by', '%s <%s>' % (usercfg['name'], usercfg['email']), None, None)) if addlink: lmsg.followup_trailers.append(('Link', linkmask % lmsg.msgid, None, None)) 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, trailer_order=trailer_order, copyccs=copyccs) 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 check_applies_clean(self, gitdir: str, at: Optional[str] = None) -> Tuple[int, list]: if self._indexes is None: self._indexes = list() seenfiles = set() for lmsg in self.patches[1:]: if lmsg is None or lmsg.blob_indexes is None: continue for fn, bh in lmsg.blob_indexes: if fn in seenfiles: # if we have seen this file once already, then it's a repeat patch # and it's no longer going to match current hash continue seenfiles.add(fn) if set(bh) == {'0'}: # New file, will for sure apply clean continue self._indexes.append((fn, bh)) 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[str] = 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 latest commit on that date guntil = pdate.strftime('%Y-%m-%d') if branches: where = ['--branches', 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 delete # 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): 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 ecode, out = git_run_command(gitdir, ['cat-file', '-e', start_commit]) if ecode > 0: stalecache = True else: ecode, out = git_run_command(gitdir, ['cat-file', '-e', end_commit]) if ecode > 0: stalecache = True else: logger.debug('Using previously generated range') return start_commit, end_commit if stalecache: logger.debug('Stale cache for [v%s] %s', self.revision, self.subject) save_cache(None, msgid, suffix='fakeam') logger.info('Preparing fake-am for v%s: %s', self.revision, self.subject) with git_temp_worktree(gitdir): # We are in a temporary chdir at this time, so writing to a known file should be safe mbxf = '.__git-am__' mbx = mailbox.mbox(mbxf) # Logic largely borrowed from gj_tools 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 fn, fi in lmsg.blob_indexes: if fn in seenfiles: # We already processed this file, so this blob won't match continue seenfiles.add(fn) if set(fi) == {'0'}: # New file creation, nothing to do here logger.debug(' New file: %s', fn) continue # Try to grab full ref_id of this hash ecode, out = git_run_command(gitdir, ['rev-parse', fi]) if ecode > 0: logger.critical(' ERROR: Could not find matching blob for %s (%s)', fn, fi) logger.critical(' If you know on which tree this patchset is based,') logger.critical(' add it as a remote and perform "git remote update"') logger.critical(' in order to fetch the missing objects.') return None, None logger.debug(' Found matching blob for: %s', fn) fullref = out.strip() gitargs = ['update-index', '--add', '--cacheinfo', f'0644,{fullref},{fn}'] ecode, out = git_run_command(None, gitargs) if ecode > 0: logger.critical(' ERROR: Could not run update-index for %s (%s)', fn, fullref) return None, None mbx.add(lmsg.msg.as_string(policy=emlpolicy).encode('utf-8')) mbx.close() 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 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() git_run_command(None, ['reset', '--hard', start_commit]) ecode, out = git_run_command(None, ['am', mbxf]) if ecode > 0: logger.critical('ERROR: Could not fake-am version %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, trailer_order=None) with open(outfile, 'w') as fh: fh.write(cover_msg.as_string(policy=emlpolicy)) logger.critical('Cover: %s', outfile) class LoreMessage: def __init__(self, msg): self.msg = msg self.msgid = None # Subject-based info self.lsubject = None self.full_subject = None self.subject = None self.reply = False self.revision = 1 self.reroll_from_revision = None self.counter = 1 self.expected = 1 self.revision_inferred = True self.counters_inferred = True # Header-based info self.in_reply_to = None self.fromname = None self.fromemail = None self.date = None # Body and body-based info self.body = None self.charset = 'utf-8' self.has_diff = False self.has_diffstat = False self.trailers = list() self.followup_trailers = list() # These are populated by pr self.pr_base_commit = None self.pr_repo = None self.pr_ref = None self.pr_tip_commit = None self.pr_remote_tip_commit = None # Patchwork hash self.pwhash = None # Blob indexes self.blob_indexes = 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 attestors property is called self._attestors = 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') 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) diffre = re.compile(r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', re.M | re.I) diffstatre = re.compile(r'^\s*\d+ file.*\d+ (insertion|deletion)', re.M | re.I) # walk until we find the first text/plain part mcharset = self.msg.get_content_charset() if not mcharset: mcharset = 'utf-8' self.charset = mcharset 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') self.charset = 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') self.charset = 'utf-8' if self.body is None: self.body = payload continue # If we already found a body, but we now find something that contains a diff, # then we prefer this part if diffre.search(payload): self.body = payload 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 diffstatre.search(self.body): self.has_diffstat = True if diffre.search(self.body): self.has_diff = True self.pwhash = LoreMessage.get_patchwork_hash(self.body) self.blob_indexes = LoreMessage.get_indexes(self.body) # We only pay attention to trailers that are sent in reply if self.reply: trailers, others = LoreMessage.find_trailers(self.body, followup=True) for trailer in trailers: # These are commonly part of patch/commit metadata badtrailers = ('from', 'author', 'cc', 'to') if trailer[0].lower() not in badtrailers: self.trailers.append(trailer) def get_trailers(self, sloppy=False): trailers = list() mismatches = set() for tname, tvalue, extdata in self.trailers: if sloppy or tname.lower() in ('fixes', 'obsoleted-by'): trailers.append((tname, tvalue, extdata, self)) continue tmatch = False namedata = email.utils.getaddresses([tvalue])[0] tfrom = re.sub(r'\+[^@]+@', '@', namedata[1].lower()) hfrom = re.sub(r'\+[^@]+@', '@', self.fromemail.lower()) tlname = namedata[0].lower() hlname = self.fromname.lower() tchunks = tfrom.split('@') hchunks = hfrom.split('@') if tfrom == hfrom: logger.debug(' trailer exact email match') tmatch = True # See if domain part of one of the addresses is a subset of the other one, # which should match cases like @linux.intel.com and @intel.com elif (len(tchunks) == 2 and len(hchunks) == 2 and tchunks[0] == hchunks[0] and (tchunks[1].find(hchunks[1]) >= 0 or hchunks[1].find(tchunks[1]) >= 0)): logger.debug(' trailer fuzzy email match') tmatch = True # Does the name match, at least? elif tlname == hlname: logger.debug(' trailer exact name match') tmatch = 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') tmatch = True if tmatch: trailers.append((tname, tvalue, extdata, self)) else: mismatches.add((tname, tvalue, extdata, self)) return trailers, mismatches @property def attestors(self): 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_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 res = dkim.verify(self.msg.as_bytes(), logger=dkimlogger) attestor = LoreAttestorDKIM(res, identity, signtime, errors) logger.debug('DKIM verify results: %s=%s', identity, res) 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(), 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 account for this in the message headers self.lsubject.subject = self.subject = i.get('Subject') self.fromname = i.get('Author') self.fromemail = i.get('Email') 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: 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(), 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) 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: if not checkmark: checkmark = attestor.checkmark if attestor.check_identity(self.fromemail): 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 clean_header(hdrval): 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() @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, header='Message-Id'): 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, msg2): 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_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]: indexes = set() curfile = None for line in diff.split('\n'): if line.find('diff ') != 0 and line.find('index ') != 0: continue matches = re.search(r'^diff\s+--git\s+\w/(.*)\s+\w/(.*)$', line) if matches and matches.groups()[0] == matches.groups()[1]: curfile = matches.groups()[0] continue matches = re.search(r'^index\s+([0-9a-f]+)\.\.[0-9a-f]+.*$', line) if matches and curfile is not None: indexes.add((curfile, matches.groups()[0])) return indexes @staticmethod def find_trailers(body, followup=False): ignores = {'phone', 'email'} headers = {'subject', 'date', 'from'} nonperson = {'fixes', 'subject', 'date', 'link', 'buglink', 'obsoleted-by'} # 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+[0-9a-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 for line in body.split('\n'): line = line.strip('\r') matches = re.search(r'^(\w\S+):\s+(\S.*)', line, flags=re.I) if matches: groups = list(matches.groups()) # We only accept headers if we haven't seen any non-trailer lines tname = groups[0].lower() if tname in ignores: logger.debug('Ignoring known non-trailer: %s', line) continue if len(others) and tname in headers: logger.debug('Ignoring %s (header after other content)', line) continue if followup: mperson = re.search(r'\S+@\S+\.\S+', groups[1]) if not mperson and tname not in nonperson: logger.debug('Ignoring %s (not a recognized non-person trailer)', line) continue was_trailer = True groups.append(None) trailers.append(groups) continue # Is it an extended info line, e.g.: # Signed-off-by: Foo Foo # [for the foo bits] if len(line) > 2 and line[0] == '[' and line[-1] == ']' and was_trailer: trailers[-1][2] = line was_trailer = False continue was_trailer = False others.append(line) return trailers, others @staticmethod def get_body_parts(body): # 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') 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, trailer_order=None, copyccs=False): config = get_main_config() attpolicy = config['attestation-policy'] bheaders, message, btrailers, basement, signature = LoreMessage.get_body_parts(self.body) # Now we add mix-in trailers trailers = btrailers + self.followup_trailers 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 ftr in trailers: if ftr[1].lower().find(pair[1].lower()) >= 0: # already present found = True break if not found: if len(pair[0]): trailers.append(('Cc', f'{pair[0]} <{pair[1]}>', None, None)) # noqa else: trailers.append(('Cc', pair[1], None, None)) # noqa fixtrailers = list() if trailer_order is None: trailer_order = DEFAULT_TRAILER_ORDER elif trailer_order in ('preserve', '_preserve_'): trailer_order = '*' for trailermatch in trailer_order: for trailer in trailers: if list(trailer[:3]) in fixtrailers: # Dupe continue if fnmatch.fnmatch(trailer[0].lower(), trailermatch.strip()): fixtrailers.append(list(trailer[:3])) if trailer[:3] not in btrailers: extra = '' if trailer[3] is not None: fmsg = trailer[3] for attestor in fmsg.attestors: # noqa if attestor.passing: extra = ' (%s %s)' % (attestor.checkmark, attestor.trailer) elif attpolicy in ('hardfail', 'softfail'): extra = ' (%s %s)' % (attestor.checkmark, attestor.trailer) if attpolicy == 'hardfail': import sys logger.critical('---') logger.critical('Exiting due to attestation-policy: hardfail') sys.exit(1) logger.info(' + %s: %s%s', trailer[0], trailer[1], extra) else: logger.debug(' . %s: %s', trailer[0], trailer[1]) # Reconstitute the message self.body = '' if bheaders: for bheader in bheaders: # There is no [extdata] in git headers, so we ignore bheader[2] self.body += '%s: %s\n' % (bheader[0], bheader[1]) self.body += '\n' if len(message): self.body += message.rstrip('\r\n') + '\n' if len(fixtrailers): self.body += '\n' if len(fixtrailers): for trailer in fixtrailers: self.body += '%s: %s\n' % (trailer[0], trailer[1]) if trailer[2]: self.body += '%s\n' % trailer[2] if len(basement): self.body += '---\n' self.body += basement.rstrip('\r\n') + '\n\n' if len(signature): self.body += '-- \n' self.body += signature.rstrip('\r\n') + '\n\n' def get_am_subject(self, indicate_reroll=True): # 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)) return '[%s] %s' % (' '.join(parts), self.lsubject.subject) def get_am_message(self, add_trailers=True, trailer_order=None, copyccs=False): if add_trailers: self.fix_trailers(trailer_order=trailer_order, copyccs=copyccs) am_msg = email.message.EmailMessage() am_msg.set_payload(self.body.encode()) am_msg.add_header('Subject', self.get_am_subject(indicate_reroll=False)) if self.fromname: am_msg.add_header('From', f'{self.fromname} <{self.fromemail}>') else: am_msg.add_header('From', self.fromemail) # Add select headers from the original message for hname in AMHDRS: hval = self.msg.get(hname) if not hval: continue hval = LoreMessage.clean_header(hval) # noinspection PyBroadException try: am_msg.add_header(hname, hval) except: # A broad except to handle any potential weird header conditions pass am_msg.set_charset('utf-8') return am_msg class LoreSubject: def __init__(self, subject): # Subject-based info self.full_subject = None self.subject = None 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,3}/\d{1,3}$', 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_slug(self): unsafe = '%04d_%s' % (self.counter, self.subject) return re.sub(r'\W+', '_', unsafe).strip('_').lower() def __repr__(self): 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) 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.endswith('@' + self.identity): logger.debug('PASS : sig domain %s matches from identity %s', self.identity, emlfrom) return True self.errors.append('signing domain %s does not match From: %s' % (self.identity, emlfrom)) return False if emlfrom == self.identity: 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]): try: return datetime.datetime.utcfromtimestamp(int(ts)).replace(tzinfo=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.identity = identity.lstrip('@') self.errors = errors 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, stdin: Optional[bytes] = None) -> Tuple[int, bytes, bytes]: 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) 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[str], args: List[str], stdin: Optional[bytes] = None, logstderr: bool = False) -> Tuple[int, str]: 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] cmdargs += args ecode, out, err = _run_command(cmdargs, stdin=stdin) out = out.decode(errors='replace') if logstderr and len(err.strip()): err = err.decode(errors='replace') logger.debug('Stderr: %s', err) out += err return ecode, out 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 @contextmanager def git_temp_worktree(gitdir=None, commitish=None): """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', dfn]) @contextmanager def git_temp_clone(gitdir=None): """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): """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 get_config_from_git(regexp: str, defaults: Optional[dict] = None, multivals: Optional[list] = None) -> dict: if multivals is None: multivals = list() args = ['config', '-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 get_main_config() -> dict: global MAIN_CONFIG if MAIN_CONFIG is None: config = get_config_from_git(r'b4\..*', defaults=DEFAULT_CONFIG, multivals=['keyringsrc']) # Legacy name was get-lore-mbox, so load those as well config = get_config_from_git(r'get-lore-mbox\..*', defaults=config) config['trailer-order'] = config['trailer-order'].split(',') config['trailer-order'].remove('*') config['trailer-order'].append('*') config['listid-preference'] = config['listid-preference'].split(',') config['listid-preference'].remove('*') config['listid-preference'].append('*') if config['gpgbin'] is None: gpgcfg = get_config_from_git(r'gpg\..*', {'program': 'gpg'}) config['gpgbin'] = gpgcfg['program'] MAIN_CONFIG = config 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, suffix=None): 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, suffix=None): 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 save_cache(contents, identifier, suffix=None, mode='w'): fullpath = get_cache_file(identifier, suffix=suffix) if not contents: # noinspection PyBroadException try: os.unlink(fullpath) logger.debug('Removed cache %s for %s', fullpath, identifier) except: pass 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 get_user_config(): global USER_CONFIG if USER_CONFIG is None: USER_CONFIG = get_config_from_git(r'user\..*') if 'name' not in USER_CONFIG: udata = pwd.getpwuid(os.getuid()) USER_CONFIG['name'] = udata.pw_gecos return USER_CONFIG def get_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(): 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) -> Optional[str]: if not cmdargs.msgid: 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 matches = re.search(r'^https?://[^/]+/([^/]+)/([^/]+@[^/]+)', msgid, re.IGNORECASE) if matches: chunks = matches.groups() msgid = urllib.parse.unquote(chunks[1]) # Infer the project name from the URL, if possible if chunks[0] != 'r': cmdargs.useproject = chunks[0] # 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, msgid): want = {msgid} got = set() seen = set() maybe = dict() strict = list() while True: for msg in msgs: c_msgid = LoreMessage.get_clean_msgid(msg) 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', [])]) for ref in set([x[1] for x in msgrefs]): 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 mbox to strict matches only (%s->%s)', len(msgs), len(strict)) return strict def mailsplit_bytes(bmbox: bytes, outdir: str) -> list: logger.debug('Mailsplitting the mbox into %s', outdir) args = ['mailsplit', '--mboxrd', '-o%s' % outdir] ecode, out = git_run_command(None, args, stdin=bmbox) msgs = list() 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)) return msgs def get_pi_thread_by_url(t_mbx_url, nocache=False): msgs = list() cachedir = get_cache_file(t_mbx_url, 'pi.msgs') if os.path.exists(cachedir) and not nocache: logger.debug('Using cached copy: %s', cachedir) for msg in os.listdir(cachedir): with open(os.path.join(cachedir, msg), 'rb') as fh: msgs.append(email.message_from_binary_file(fh)) else: logger.critical('Grabbing thread from %s', t_mbx_url.split('://')[1]) session = get_requests_session() resp = session.get(t_mbx_url) if resp.status_code != 200: logger.critical('Server returned an error: %s', resp.status_code) return None t_mbox = gzip.decompress(resp.content) resp.close() if not len(t_mbox): logger.critical('No messages found for that query') return None # Convert into individual files using git-mailsplit with tempfile.TemporaryDirectory(suffix='-mailsplit') as tfd: msgs = mailsplit_bytes(t_mbox, tfd) if os.path.exists(cachedir): shutil.rmtree(cachedir) shutil.copytree(tfd, cachedir) deduped = dict() for msg in msgs: msgid = LoreMessage.get_clean_msgid(msg) if msgid in deduped: deduped[msgid] = LoreMessage.get_preferred_duplicate(deduped[msgid], msg) continue deduped[msgid] = msg return list(deduped.values()) def get_pi_thread_by_msgid(msgid, useproject=None, nocache=False, onlymsgids: Optional[set] = None): qmsgid = urllib.parse.quote_plus(msgid) config = get_main_config() # Grab the head from lore, to see where we are redirected midmask = config['midmask'] % qmsgid loc = urllib.parse.urlparse(midmask) if useproject: projurl = '%s://%s/%s' % (loc.scheme, loc.netloc, useproject) else: 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 for onlymsgid in onlymsgids: if msg.get('references', '').find(onlymsgid) >= 0: strict.append(msg) else: strict = get_strict_thread(msgs, msgid) return strict @contextmanager def git_format_patches(gitdir, start, end, prefixes=None, extraopts=None): with tempfile.TemporaryDirectory() as tmpd: gitargs = ['format-patch', '--cover-letter', '-o', tmpd, '--signature', f'b4 {__VERSION__}'] if prefixes is not None and len(prefixes): gitargs += ['--subject-prefix', ' '.join(prefixes)] if extraopts: gitargs += extraopts gitargs += ['%s..%s' % (start, end)] ecode, out = git_run_command(gitdir, gitargs) if ecode > 0: logger.critical('ERROR: Could not convert pull request into patches') logger.critical(out) yield None yield tmpd def git_commit_exists(gitdir, commit_id): gitargs = ['cat-file', '-e', commit_id] ecode, out = git_run_command(gitdir, gitargs) return ecode == 0 def git_branch_contains(gitdir, commit_id): gitargs = ['branch', '--format=%(refname:short)', '--contains', commit_id] lines = git_get_command_lines(gitdir, gitargs) return lines def git_get_toplevel(path=None): 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, clean=True): addrs = set() for pair in pairs: pair = list(pair) if pair[0] == pair[1]: pair[0] = '' if clean: # Remove any quoted-printable header junk from the name pair[0] = LoreMessage.clean_header(pair[0]) addrs.add(email.utils.formataddr(pair)) # noqa return ', '.join(addrs) def make_quote(body, maxlines=5): 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, upper=None): # 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 ([0-9A-F]+)\s+(.*)$', status, flags=re.M) if bs_matches: keyid = bs_matches.groups()[0] return good, valid, trusted, keyid, signtime gs_matches = re.search(r'^\[GNUPG:] GOODSIG ([0-9A-F]+)\s+(.*)$', status, flags=re.M) if gs_matches: good = True keyid = gs_matches.groups()[0] vs_matches = re.search(r'^\[GNUPG:] VALIDSIG ([0-9A-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', status, flags=re.M) 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: 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, dest: TextIO): # 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 gen = email.generator.Generator(dest, policy=emlpolicy) for msg in msgs: msg.set_unixfrom('From git@z Thu Jan 1 00:00:00 1970') gen.flatten(msg, unixfrom=True) gen.write('\n') def save_maildir(msgs: list, dest): 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(msg.as_string(policy=emlpolicy).encode()) 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 b4-0.8.0/b4/attest.py000066400000000000000000000016031411367610500142130ustar00rootroot00000000000000#!/usr/bin/env python3 # -*- coding: utf-8 -*- # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2020 by the Linux Foundation # import sys import b4 import argparse try: import patatt can_patatt = True except ModuleNotFoundError: can_patatt = False from collections import namedtuple logger = b4.logger def attest_patches(cmdargs: argparse.Namespace) -> None: if not can_patatt: logger.critical('ERROR: b4 now uses patatt for patch attestation. See:') logger.critical(' https://git.kernel.org/pub/scm/utils/patatt/patatt.git/about/') sys.exit(1) # directly invoke cmd_sign in patatt config = patatt.get_config_from_git(r'patatt\..*', multivals=['keyringsrc']) fakeargs = namedtuple('Struct', ['hookmode', 'msgfile']) fakeargs.hookmode = True fakeargs.msgfile = cmdargs.patchfile patatt.cmd_sign(fakeargs, config) b4-0.8.0/b4/command.py000066400000000000000000000322251411367610500143310ustar00rootroot00000000000000#!/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('-p', '--use-project', dest='useproject', default=None, help='Use a specific project instead of guessing (linux-mm, linux-hardening, etc)') sp.add_argument('-m', '--use-local-mbox', dest='localmbox', default=None, help='Instead of grabbing a thread from lore, process this mbox file (or - for stdin)') sp.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False, help='Do not use local cache') 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_mbox(cmdargs): import b4.mbox b4.mbox.main(cmdargs) def cmd_kr(cmdargs): import b4.kr b4.kr.main(cmdargs) def cmd_am(cmdargs): import b4.mbox b4.mbox.main(cmdargs) def cmd_attest(cmdargs): import b4.attest if len(cmdargs.patchfile): b4.attest.attest_patches(cmdargs) else: logger.critical('ERROR: missing patches to attest') sys.exit(1) def cmd_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) def cmd(): # noinspection PyTypeChecker parser = argparse.ArgumentParser( prog='b4', description='A tool to work with public-inbox patches', 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') 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.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) sp_am.add_argument('-v', '--use-version', dest='wantver', type=int, default=None, help='Get a specific version of the patch/series') sp_am.add_argument('-t', '--apply-cover-trailers', dest='covertrailers', action='store_true', default=False, help='Apply trailers sent to the cover letter to all patches') sp_am.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False, help='Apply trailers without email address match checking') sp_am.add_argument('-T', '--no-add-trailers', dest='noaddtrailers', action='store_true', default=False, help='Do not add or sort any trailers') sp_am.add_argument('-s', '--add-my-sob', dest='addmysob', action='store_true', default=False, help='Add your own signed-off-by to every patch') sp_am.add_argument('-l', '--add-link', dest='addlink', action='store_true', default=False, help='Add a lore.kernel.org/r/ link to every patch') sp_am.add_argument('-Q', '--quilt-ready', dest='quiltready', action='store_true', default=False, help='Save patches in a quilt-ready folder') sp_am.add_argument('-P', '--cherry-pick', dest='cherrypick', default=None, help='Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", ' '"-P _" to use just the msgid specified, or ' '"-P *globbing*" to match on commit subject)') sp_am.add_argument('-g', '--guess-base', dest='guessbase', action='store_true', default=False, help='Try to guess the base of the series (if not specified)') sp_am.add_argument('-b', '--guess-branch', dest='guessbranch', default=None, help='When guessing base, restrict to this branch (use with -g)') sp_am.add_argument('--guess-lookback', dest='guessdays', type=int, default=14, help='When guessing base, go back this many days from the date of the patch') sp_am.add_argument('-3', '--prep-3way', dest='threeway', action='store_true', default=False, help='Prepare for a 3-way merge ' '(tries to ensure that all index blobs exist by making a fake commit range)') sp_am.add_argument('--cc-trailers', dest='copyccs', action='store_true', default=False, help='Copy all Cc\'d addresses into Cc: trailers') sp_am.add_argument('--no-cover', dest='nocover', action='store_true', default=False, help='Do not save the cover letter (on by default when using -o -)') sp_am.add_argument('--no-partial-reroll', dest='nopartialreroll', action='store_true', default=False, help='Do not reroll partial series when detected') sp_am.set_defaults(func=cmd_am) # b4 attest sp_att = subparsers.add_parser('attest', help='Create cryptographic attestation for a set of patches') sp_att.add_argument('-f', '--from', dest='sender', default=None, help='OBSOLETE: this option does nothing and will be removed') sp_att.add_argument('-n', '--no-submit', dest='nosubmit', action='store_true', default=False, help='OBSOLETE: this option does nothing and will be removed') sp_att.add_argument('-o', '--output', default=None, help='OBSOLETE: this option does nothing and will be removed') sp_att.add_argument('-m', '--mutt-filter', default=None, help='OBSOLETE: this option does nothing and will be removed') sp_att.add_argument('patchfile', nargs='*', help='Patches to attest') sp_att.set_defaults(func=cmd_attest) # b4 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('-l', '--retrieve-links', action='store_true', dest='getlinks', default=False, help='Attempt to retrieve any Link: URLs (use with -e)') 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('-s', '--send', 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.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('-p', '--use-project', dest='useproject', default=None, help='Use a specific project instead of guessing (linux-mm, linux-hardening, etc)') sp_diff.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False, help='Do not use local cache') sp_diff.add_argument('-v', '--compare-versions', dest='wantvers', type=int, default=None, nargs='+', 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) 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) 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.8.0/b4/diff.py000066400000000000000000000126161411367610500136250ustar00rootroot00000000000000#!/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 logger = b4.logger def diff_same_thread_series(cmdargs): msgid = b4.get_msgid(cmdargs) 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]) if cmdargs.useproject: identifier += '-' + cmdargs.useproject 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, useproject=cmdargs.useproject, nocache=cmdargs.nocache) if not msgs: logger.critical('Unable to retrieve thread: %s', msgid) return msgs = b4.mbox.get_extra_series(msgs, direction=-1, wantvers=wantvers, useproject=cmdargs.useproject) 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 = min(lmbx.series.keys()) 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): chunks = list() for mboxfile in cmdargs.ambox: if not os.path.exists(mboxfile): logger.critical('Cannot open %s', mboxfile) return None, 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): 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('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.8.0/b4/kr.py000066400000000000000000000061171411367610500133300ustar00rootroot00000000000000#!/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): msgid, msgs = b4.mbox.get_msgs(cmdargs) if cmdargs.showkeys: 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) b4-0.8.0/b4/mbox.py000066400000000000000000000605431411367610500136640ustar00rootroot00000000000000#!/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 tempfile import urllib.parse import xml.etree.ElementTree import b4 from typing import Optional, Tuple logger = b4.logger def make_am(msgs, cmdargs, msgid): config = b4.get_main_config() outdir = cmdargs.outdir if outdir == '-': cmdargs.nocover = True wantver = cmdargs.wantver wantname = cmdargs.wantname covertrailers = cmdargs.covertrailers count = len(msgs) logger.info('Analyzing %s messages in the thread', count) lmbx = b4.LoreMailbox() # Go through the mbox once to populate base series for msg in msgs: lmbx.add_message(msg) reroll = True if cmdargs.nopartialreroll: reroll = False lser = lmbx.get_series(revision=wantver, sloppytrailers=cmdargs.sloppytrailers, reroll=reroll) if lser is None and wantver is None: logger.critical('No patches found.') return if lser is None: 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 == '_': # 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 try: am_msgs = lser.get_am_ready(noaddtrailers=cmdargs.noaddtrailers, covertrailers=covertrailers, trailer_order=config['trailer-order'], addmysob=cmdargs.addmysob, addlink=cmdargs.addlink, linkmask=config['linkmask'], cherrypick=cherrypick, copyccs=cmdargs.copyccs) except KeyError: sys.exit(1) if cmdargs.maildir or config.get('save-maildirs', 'no') == 'yes': save_maildir = True dftext = 'maildir' else: save_maildir = False dftext = 'mbx' if wantname: slug = wantname if wantname.find('.') > -1: slug = '.'.join(wantname.split('.')[:-1]) gitbranch = slug else: slug = lser.get_slug(extended=True) gitbranch = lser.get_slug(extended=False) if outdir != '-': am_filename = os.path.join(outdir, f'{slug}.{dftext}') am_cover = os.path.join(outdir, f'{slug}.cover') if os.path.exists(am_filename): if os.path.isdir(am_filename): shutil.rmtree(am_filename) else: os.unlink(am_filename) if save_maildir: b4.save_maildir(am_msgs, am_filename) else: with open(am_filename, 'w') as fh: b4.save_git_am_mbox(am_msgs, fh) else: am_filename = None am_cover = None b4.save_git_am_mbox(am_msgs, sys.stdout) logger.info('---') if cherrypick is None: logger.critical('Total patches: %s', len(am_msgs)) else: logger.info('Total patches: %s (cherrypicked: %s)', len(am_msgs), cmdargs.cherrypick) # Check if any of the followup-trailers is an Obsoleted-by if not cmdargs.checknewer: warned = False for lmsg in lser.patches: # Only check cover letter or first patch if not lmsg or lmsg.counter > 1: continue for trailer in list(lmsg.followup_trailers): if trailer[0].lower() == 'obsoleted-by': lmsg.followup_trailers.remove(trailer) if warned: continue logger.critical('---') logger.critical('WARNING: Found an Obsoleted-by follow-up trailer!') logger.critical(' Rerun with -c to automatically retrieve the new series.') warned = True if lser.has_cover and lser.patches[0].followup_trailers and not covertrailers: # Warn that some trailers were sent to the cover letter logger.critical('---') logger.critical('NOTE: Some trailers were sent to the cover letter:') tseen = set() for trailer in lser.patches[0].followup_trailers: if tuple(trailer[:2]) not in tseen: logger.critical(' %s: %s', trailer[0], trailer[1]) tseen.add(tuple(trailer[:2])) logger.critical('NOTE: Rerun with -t to apply them to all patches') 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') 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('Prepared 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!') if lser.has_cover and not cmdargs.nocover: lser.save_cover(am_cover) 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 linkurl = config['linkmask'] % top_msgid if cmdargs.quiltready: q_dirname = os.path.join(outdir, f'{slug}.patches') save_as_quilt(am_msgs, q_dirname) logger.critical('Quilt: %s', q_dirname) logger.critical(' Link: %s', linkurl) base_commit = None matches = re.search(r'base-commit: .*?([0-9a-f]+)', first_body, re.MULTILINE) if matches: base_commit = matches.groups()[0] else: # Try a more relaxed search matches = re.search(r'based on .*?([0-9a-f]{40})', first_body, re.MULTILINE) if matches: base_commit = matches.groups()[0] if base_commit: logger.critical(' Base: %s', base_commit) else: if topdir is not None: if cmdargs.guessbase: logger.critical(' attempting to guess base-commit...') 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') else: checked, mismatches = lser.check_applies_clean(topdir, at=cmdargs.guessbranch) if checked and len(mismatches) == 0 and checked != mismatches: logger.critical(' Base: applies clean to current tree') else: logger.critical(' Base: not specified') 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', am_filename) thanks_record_am(lser, cherrypick=cherrypick) def thanks_record_am(lser, cherrypick=None): # Are we tracking this already? datadir = b4.get_data_dir() slug = lser.get_slug(extended=True) filename = '%s.am' % slug patches = list() at = 0 padlen = len(str(lser.expected)) lmsg = None for pmsg in lser.patches: if pmsg is None: at += 1 continue 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 allto = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('to', [])]) allcc = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('cc', [])]) 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) def save_as_quilt(am_msgs, q_dirname): 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, useproject: Optional[str] = None) -> list: base_msg = None latest_revision = None seen_msgids = set() seen_covers = set() obsoleted = list() for msg in msgs: msgid = b4.LoreMessage.get_clean_msgid(msg) seen_msgids.add(msgid) lsub = b4.LoreSubject(msg['Subject']) if direction > 0 and lsub.reply: # Does it have an "Obsoleted-by: trailer? rmsg = b4.LoreMessage(msg) trailers, mismatches = rmsg.get_trailers() for tl in trailers: if tl[0].lower() == 'obsoleted-by': for chunk in tl[1].split('/'): if chunk.find('@') > 0 and chunk not in seen_msgids: obsoleted.append(chunk) break # Ignore patches above 1 if lsub.counter > 1: continue 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 base_msg is None: logger.debug('Could not find cover of 1st patch in mbox') return msgs config = b4.get_main_config() loc = urllib.parse.urlparse(config['midmask']) if not useproject: useproject = 'all' listarc = f'{loc.scheme}://{loc.netloc}/{useproject}/' # Make sure it exists queryurl = f'{listarc}_/text/config/raw' session = b4.get_requests_session() resp = session.get(queryurl) if not resp.status_code == 200: logger.info('Unable to figure out list archive location') return msgs nt_msgs = list() if len(obsoleted): for nt_msgid in obsoleted: logger.info('Obsoleted-by: %s', nt_msgid) # Grab this thread from remote t_mbx_url = '%s/%s/t.mbox.gz' % (listarc.rstrip('/'), nt_msgid) potentials = b4.get_pi_thread_by_url(t_mbx_url, nocache=nocache) if potentials: potentials = b4.get_strict_thread(potentials, nt_msgid) nt_msgs += potentials logger.info(' Added %s messages from that thread', len(potentials)) else: logger.info(' No messages added from that thread') else: # 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 latest version of the series') return msgs if direction < 0 and wantvers is None: wantvers = [latest_revision - 1] base_msgid = b4.LoreMessage.get_clean_msgid(base_msg) fromeml = email.utils.getaddresses(base_msg.get_all('from', []))[0][1] msgdate = email.utils.parsedate_tz(str(base_msg['Date'])) startdate = time.strftime('%Y%m%d', msgdate[:9]) if direction > 0: q = 's:"%s" AND f:"%s" AND d:%s..' % (lsub.subject.replace('"', ''), fromeml, startdate) queryurl = '%s?%s' % (listarc, urllib.parse.urlencode({'q': q, 'x': 'A', 'o': '-1'})) logger.critical('Checking for newer revisions on %s', listarc) else: q = 's:"%s" AND f:"%s" AND d:..%s' % (lsub.subject.replace('"', ''), fromeml, startdate) queryurl = '%s?%s' % (listarc, urllib.parse.urlencode({'q': q, 'x': 'A', 'o': '1'})) logger.critical('Checking for older revisions on %s', listarc) logger.debug('Query URL: %s', queryurl) session = b4.get_requests_session() resp = session.get(queryurl) # try to parse it try: tree = xml.etree.ElementTree.fromstring(resp.content) except xml.etree.ElementTree.ParseError as ex: logger.debug('Unable to parse results, ignoring: %s', ex) resp.close() return msgs resp.close() ns = {'atom': 'http://www.w3.org/2005/Atom'} entries = tree.findall('atom:entry', ns) seen_urls = set() for entry in entries: title = entry.find('atom:title', ns).text lsub = b4.LoreSubject(title) if lsub.reply or lsub.counter > 1: logger.debug('Ignoring result (not interesting): %s', title) continue link = entry.find('atom:link', ns).get('href') if direction > 0 and lsub.revision <= latest_revision: logger.debug('Ignoring result (not new revision): %s', title) continue elif direction < 0 and lsub.revision >= latest_revision: logger.debug('Ignoring result (not old revision): %s', title) continue elif direction < 0 and lsub.revision not in wantvers: logger.debug('Ignoring result (not revision we want): %s', title) continue if link.find('/%s/' % base_msgid) > 0: logger.debug('Ignoring result (same thread as ours):%s', title) 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', title) else: # It's *probably* an older revision. logger.debug('Likely an older revision: %s', title) elif direction > 0 and lsub.revision > latest_revision: logger.debug('Definitely a new revision [v%s]: %s', lsub.revision, title) elif direction < 0 and lsub.revision < latest_revision: logger.debug('Definitely an older revision [v%s]: %s', lsub.revision, title) else: logger.debug('No idea what this is: %s', title) continue t_mbx_url = '%st.mbox.gz' % link if t_mbx_url in seen_urls: continue seen_urls.add(t_mbx_url) logger.info('New revision: %s', title) potentials = b4.get_pi_thread_by_url(t_mbx_url, nocache=nocache) if potentials: nt_msgs += potentials logger.info(' Added %s messages from that thread', len(potentials)) # Append all of these to the existing mailbox for nt_msg in nt_msgs: nt_msgid = b4.LoreMessage.get_clean_msgid(nt_msg) if nt_msgid in seen_msgids: logger.debug('Duplicate message, skipping') continue nt_subject = re.sub(r'\s+', ' ', nt_msg['Subject']) logger.debug('Adding: %s', nt_subject) msgs.append(nt_msg) seen_msgids.add(nt_msgid) return msgs def get_msgs(cmdargs) -> Tuple[Optional[str], Optional[list]]: msgid = None if not cmdargs.localmbox: msgid = b4.get_msgid(cmdargs) if not msgid: logger.error('Error: pipe a message or pass msgid as parameter') sys.exit(1) pickings = set() try: if cmdargs.cherrypick == '_': # Just that msgid, please pickings = {msgid} except AttributeError: pass msgs = b4.get_pi_thread_by_msgid(msgid, useproject=cmdargs.useproject, nocache=cmdargs.nocache, onlymsgids=pickings) if not msgs: 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 = b4.mailsplit_bytes(sys.stdin.buffer.read(), tfd) if not len(msgs): logger.critical('Stdin did not contain any messages') sys.exit(1) elif os.path.exists(cmdargs.localmbox): msgid = b4.get_msgid(cmdargs) if os.path.isdir(cmdargs.localmbox): in_mbx = mailbox.Maildir(cmdargs.localmbox) else: in_mbx = mailbox.mbox(cmdargs.localmbox) if msgid: msgs = b4.get_strict_thread(in_mbx, msgid) if not msgs: logger.critical('Could not find %s in %s', msgid, cmdargs.localmbox) sys.exit(1) else: msgs = in_mbx else: logger.critical('Mailbox %s does not exist', cmdargs.localmbox) sys.exit(1) 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 main(cmdargs): if cmdargs.checknewer: # Force nocache mode cmdargs.nocache = True msgid, msgs = get_msgs(cmdargs) if not msgs: return if len(msgs) and cmdargs.checknewer: msgs = get_extra_series(msgs, direction=1, useproject=cmdargs.useproject) if cmdargs.subcmd == 'am': make_am(msgs, cmdargs, msgid) return logger.info('%s messages in the thread', len(msgs)) if cmdargs.outdir == '-': logger.info('---') b4.save_git_am_mbox(msgs, sys.stdout) return # Check if outdir is a maildir if (os.path.isdir(os.path.join(cmdargs.outdir, 'new')) and os.path.isdir(os.path.join(cmdargs.outdir, 'cur')) and os.path.isdir(os.path.join(cmdargs.outdir, 'tmp'))): 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, 'w') as fh: b4.save_git_am_mbox(msgs, fh) logger.info('Saved %s', savename) b4-0.8.0/b4/pr.py000066400000000000000000000555341411367610500133440ustar00rootroot00000000000000#!/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 mailbox import json import email import gzip import urllib.parse import requests from datetime import datetime, timedelta from email import utils, charset from email.mime.text import MIMEText from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart charset.add_charset('utf-8', None) logger = b4.logger PULL_BODY_SINCE_ID_RE = [ re.compile(r'changes since commit ([0-9a-f]{5,40}):', re.M | re.I) ] # I like these PULL_BODY_WITH_COMMIT_ID_RE = [ re.compile(r'fetch changes up to ([0-9a-f]{5,40}):', re.M | re.I), ] # 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 format_addrs(pairs): return ', '.join([utils.formataddr(pair) for pair in pairs]) def git_get_commit_id_from_repo_ref(repo, ref): # 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: 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): 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, lmsg): 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, lmsg, branch=None, check_sig=True, ty_track=True): # 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): 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) def explode(gitdir, lmsg, mailfrom=None, retrieve_links=True, fpopts=None): 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') msgs = list() prefixes = ['PATCH'] 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', [])) if mailfrom is None: mailfrom = b4.LoreMessage.clean_header(lmsg.msg.get('From')) else: realname = None for fromaddr in utils.getaddresses(lmsg.msg.get_all('from', [])): realname = fromaddr[0] if not realname: realname = fromaddr[1] if fromaddr not in allcc: allcc.append(fromaddr) if realname: # Use "Name via Foo" notation if mailfrom.find('@') > 0 > mailfrom.find('<'): mailfrom = f'<{mailfrom}>' mailfrom = f'{realname} via {mailfrom}' config = b4.get_main_config() linked_ids = set() if retrieve_links: # Insert the pull request itself into linked_ids, so we preserve it as part # of the archived threads. linked_ids.add(lmsg.msgid) with b4.git_format_patches(gitdir, lmsg.pr_base_commit, 'FETCH_HEAD', prefixes=prefixes, extraopts=fpopts) as pdir: if pdir is None: raise RuntimeError('Could not run format-patches') for msgfile in sorted(os.listdir(pdir)): with open(os.path.join(pdir, msgfile), 'rb') as fh: msg = email.message_from_binary_file(fh) msubj = b4.LoreSubject(msg.get('subject', '')) # Is this the cover letter? if msubj.counter == 0: # We rebuild the message from scratch # The cover letter body is the pull request body, plus a few trailers body = '%s\n\nbase-commit: %s\nPR-Link: %s\n' % ( lmsg.body.strip(), lmsg.pr_base_commit, config['linkmask'] % lmsg.msgid) # Make it a multipart if we're doing retrieve_links if retrieve_links: cmsg = MIMEMultipart() cmsg.attach(MIMEText(body, 'plain')) else: cmsg = email.message.EmailMessage() cmsg.set_payload(body) cmsg.add_header('From', mailfrom) cmsg.add_header('Subject', '[' + ' '.join(msubj.prefixes) + '] ' + lmsg.subject) cmsg.add_header('Date', lmsg.msg.get('Date')) cmsg.set_charset('utf-8') cmsg.replace_header('Content-Transfer-Encoding', '8bit') msg = cmsg else: # Move the original From and Date into the body prepend = list() if msg.get('From') != mailfrom: cleanfrom = b4.LoreMessage.clean_header(msg['from']) prepend.append('From: %s' % ''.join(cleanfrom)) msg.replace_header('From', mailfrom) prepend.append('Date: %s' % msg['date']) body = '%s\n\n%s' % ('\n'.join(prepend), msg.get_payload(decode=True).decode('utf-8')) msg.set_payload(body) msg.replace_header('Subject', msubj.full_subject) if retrieve_links: matches = re.findall(r'^Link:\s+https?://.*/(\S+@\S+)[^/]', body, flags=re.M | re.I) if matches: linked_ids.update(matches) matches = re.findall(r'^Message-ID:\s+(\S+@\S+)', body, flags=re.M | re.I) if matches: linked_ids.update(matches) # Add a number of seconds equalling the counter, in hopes it gets properly threaded newdate = lmsg.date + timedelta(seconds=msubj.counter) msg.replace_header('Date', utils.format_datetime(newdate)) # Thread it to the cover letter msg.add_header('In-Reply-To', '' % lmsg.msgid) msg.add_header('References', '' % lmsg.msgid) msg.add_header('To', format_addrs(allto)) if allcc: msg.add_header('Cc', format_addrs(allcc)) # Set the message-id based on the original pull request msgid msg.add_header('Message-Id', '' % (msubj.counter, lmsg.msgid)) if mailfrom != lmsg.msg.get('From'): msg.add_header('Reply-To', lmsg.msg.get('From')) msg.add_header('X-Original-From', lmsg.msg.get('From')) if lmsg.msg['List-Id']: msg.add_header('X-Original-List-Id', b4.LoreMessage.clean_header(lmsg.msg['List-Id'])) logger.info(' %s', msg.get('Subject')) msg.set_charset('utf-8') msgs.append(msg) logger.info('Exploded %s messages', len(msgs)) if retrieve_links and linked_ids: with tempfile.TemporaryDirectory() as tfd: # Create a single mbox file with all linked conversations mbf = os.path.join(tfd, 'linked.mbox') tmbx = mailbox.mbox(mbf) logger.info('---') logger.info('Retrieving %s linked conversations', len(linked_ids)) seen_msgids = set() for msgid in linked_ids: # Did we already retrieve it as part of a previous tread? if msgid in seen_msgids: continue lmsgs = b4.get_pi_thread_by_msgid(msgid) if lmsgs: # Append any messages we don't yet have for lmsg in lmsgs: amsgid = b4.LoreMessage.get_clean_msgid(lmsg) if amsgid not in seen_msgids: seen_msgids.add(amsgid) logger.debug('Added linked: %s', lmsg.get('Subject')) tmbx.add(lmsg.as_string(policy=b4.emlpolicy).encode()) if len(tmbx): tmbx.close() # gzip the mailbox and attach it to the cover letter with open(mbf, 'rb') as fh: mbz = gzip.compress(fh.read()) fname = 'linked-threads.mbox.gz' att = MIMEApplication(mbz, 'x-gzip') att.add_header('Content-Disposition', f'attachment; filename={fname}') msgs[0].attach(att) logger.info('---') if len(seen_msgids): logger.info('Attached %s messages as linked-threads.mbox.gz', len(seen_msgids)) else: logger.info('Could not retrieve any linked threads') return msgs def get_pr_from_github(ghurl: str): 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() msg = email.message.EmailMessage() msg.set_payload(prdata.get('body', '(no body)')) 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['From'] = f'{uname} <{uemail}>' title = prdata.get('title', '') msg['Subject'] = f'[GIT PULL] {title}' msg['To'] = fake_email 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 # We are going to turn it into bytes and then parse again # in order to avoid bugs with python's message parsing routines that # end up not doing the right thing when decoding 8bit message bodies msg.set_charset('utf-8') msg.replace_header('Content-Transfer-Encoding', '8bit') bug_avoidance = msg.as_string(policy=b4.emlpolicy).encode() cmsg = email.message_from_bytes(bug_avoidance) lmsg = b4.LoreMessage(cmsg) 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): gitdir = cmdargs.gitdir lmsg = None if 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, retrieve_links=cmdargs.getlinks) 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_string(policy=b4.emlpolicy).encode()) 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, 'w') 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.8.0/b4/ty.py000066400000000000000000000531071411367610500133510ustar00rootroot00000000000000#!/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 json from string import Template from email import utils from pathlib import Path 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, commit_id, branch=None): # 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, rev): args = ['diff', '%s~..%s' % (rev, rev)] return b4.git_run_command(gitdir, args) def git_get_commit_message(gitdir, rev): args = ['log', '--format=%B', '-1', rev] return b4.git_run_command(gitdir, args) def make_reply(reply_template, jsondata): body = Template(reply_template).safe_substitute(jsondata) # Conform to email standards body = body.replace('\n', '\r\n') msg = email.message_from_string(body) msg['From'] = '%s <%s>' % (jsondata['myname'], jsondata['myemail']) allto = utils.getaddresses([jsondata['to']]) allcc = utils.getaddresses([jsondata['cc']]) # Remove ourselves and original sender from allto or allcc for entry in list(allto): if entry[1] == jsondata['myemail'] or entry[1] == jsondata['fromemail']: allto.remove(entry) for entry in list(allcc): if entry[1] == jsondata['myemail'] or entry[1] == jsondata['fromemail']: allcc.remove(entry) # Add original sender to the To allto.append((jsondata['fromname'], jsondata['fromemail'])) msg['To'] = b4.format_addrs(allto) if allcc: msg['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['Subject'] = 'Re: (subset) ' + subject else: msg['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) return msg def auto_locate_pr(gitdir, jsondata, branch): 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, branch, since='1.week', committer=None): 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', '--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, jsondata, branch, since='1.week'): 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 read_template(tptfile): # bubbles up FileNotFound tpt = '' if tptfile.find('~') >= 0: tptfile = os.path.expanduser(tptfile) if tptfile.find('$') >= 0: tptfile = os.path.expandvars(tptfile) with open(tptfile, 'r', encoding='utf-8') as fh: for line in fh: if len(line) and line[0] == '#': continue tpt += line return tpt def set_branch_details(gitdir, branch, jsondata, config): binfo = get_branch_info(gitdir, branch) jsondata['branch'] = branch 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, jsondata, branch): 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 = 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) return msg def generate_am_thanks(gitdir, jsondata, branch, since): 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 = 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, 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) return msg def auto_thankanator(cmdargs): 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, cmdargs.gitdir, cmdargs.outdir, wantbranch, since=cmdargs.since) sys.exit(0) def send_messages(listing, gitdir, outdir, branch, since='1.week'): # Not really sending, but writing them out to be sent on your own # We'll probably gain ability to send these once the feature is # more mature and we're less likely to mess things up datadir = b4.get_data_dir() logger.info('Generating %s thank-you letters', len(listing)) # Check if the outdir exists and if it has any .thanks files in it if not os.path.exists(outdir): os.mkdir(outdir) usercfg = b4.get_user_config() # Do we have a .signature file? sigfile = os.path.join(str(Path.home()), '.signature') if os.path.exists(sigfile): with open(sigfile, 'r', encoding='utf-8') as fh: signature = fh.read() else: signature = '%s <%s>' % (usercfg['name'], usercfg['email']) outgoing = 0 for jsondata in listing: slug_from = re.sub(r'\W', '_', jsondata['fromemail']) slug_subj = re.sub(r'\W', '_', jsondata['subject']) slug = '%s_%s' % (slug_from.lower(), slug_subj.lower()) slug = re.sub(r'_+', '_', slug) jsondata['myname'] = usercfg['name'] jsondata['myemail'] = usercfg['email'] jsondata['signature'] = signature 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, since) if msg is None: continue outgoing += 1 outfile = os.path.join(outdir, '%s.thanks' % slug) logger.info(' Writing: %s', outfile) msg.set_charset('utf-8') msg.replace_header('Content-Transfer-Encoding', '8bit') with open(outfile, 'w') as fh: fh.write(msg.as_string(policy=b4.emlpolicy)) logger.debug('Cleaning up: %s', jsondata['trackfile']) fullpath = os.path.join(datadir, jsondata['trackfile']) os.rename(fullpath, '%s.sent' % fullpath) logger.info('---') if not outgoing: logger.info('No thanks necessary.') return logger.debug('Wrote %s thank-you letters', outgoing) logger.info('You can now run:') logger.info(' git send-email %s/*.thanks', outdir) def list_tracked(): # 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): 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 send_selected(cmdargs): tracked = list_tracked() if not len(tracked): logger.info('Nothing to do') sys.exit(0) if cmdargs.send == 'all': listing = tracked else: listing = list() for num in b4.parse_int_range(cmdargs.send, 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, cmdargs.gitdir, cmdargs.outdir, wantbranch, cmdargs.since) sys.exit(0) def discard_selected(cmdargs): 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)) for jsondata in listing: fullpath = os.path.join(datadir, jsondata['trackfile']) os.rename(fullpath, '%s.discarded' % fullpath) logger.info(' Discarded: %s', jsondata['subject']) sys.exit(0) def check_stale_thanks(outdir): 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): 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, branch): 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): 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.send: check_stale_thanks(cmdargs.outdir) send_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 -s 1-3,5,7-') b4-0.8.0/man/000077500000000000000000000000001411367610500126035ustar00rootroot00000000000000b4-0.8.0/man/b4.5000066400000000000000000000330651411367610500132050ustar00rootroot00000000000000.\" Man page generated from reStructuredText. . .TH B4 5 "2021-09-01" "0.8.0" "" .SH NAME B4 \- Work with code submissions in a public-inbox archive . .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 .. .SH SYNOPSIS .sp b4 {mbox,am,attest,pr,ty,diff} [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 "b4" was chosen for ease of typing and because B\-4 was the precursor to Lore and Data in the Star Trek universe. .SH SUBCOMMANDS .INDENT 0.0 .IP \(bu 2 \fIb4 mbox\fP: Download a thread as an mbox file .IP \(bu 2 \fIb4 am\fP: Create an mbox file that is ready to git\-am .IP \(bu 2 \fIb4 pr\fP: Work with pull requests .IP \(bu 2 \fIb4 diff\fP: Show range\-diff style diffs between patch versions .IP \(bu 2 \fIb4 ty\fP: Create templated replies for processed patches and pull requests .IP \(bu 2 \fIb4 attest\fP: (EXPERIMENTAL) Add cryptographic attestation to patches .IP \(bu 2 \fIb4 kr\fP (EXPERIMENTAL) Operate on patatt\-compatible keyrings .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) .UNINDENT .SH SUBCOMMAND OPTIONS .SS b4 mbox .INDENT 0.0 .TP .B usage: b4 mbox [\-h] [\-o OUTDIR] [\-p USEPROJECT] [\-c] [\-n WANTNAME] [\-m LOCALMBOX] [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 \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR Output into this directory (or use \- to output mailbox contents to stdout) .TP .BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT Use a specific project instead of guessing (linux\-mm, linux\-hardening, etc) .TP .B \-c\fP,\fB \-\-check\-newer\-revisions Check if newer patch revisions exist .TP .BI \-n \ WANTNAME\fR,\fB \ \-\-mbox\-name \ WANTNAME Filename to name the mbox file .TP .BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX Instead of grabbing a thread from lore, process this mbox file (or use \- for stdin) .TP .B \-C\fP,\fB \-\-no\-cache Do not use local cache .TP .B \-f\fP,\fB \-\-filter\-dupes When adding messages to existing maildir, filter out duplicates .TP .B \-M\fP,\fB \-\-save\-as\-maildir Save as maildir (avoids mbox format ambiguities) .UNINDENT .UNINDENT .sp \fIExample\fP: b4 mbox \fI\%20200313231252.64999\-1\-keescook@chromium.org\fP .SS b4 am .INDENT 0.0 .TP .B usage: b4 am [\-h] [\-o OUTDIR] [\-p USEPROJECT] [\-c] [\-n WANTNAME] [\-m LOCALMBOX] [\-v WANTVER] [\-t] [\-T] [\-s] [\-l] [\-Q] [msgid] .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 \-o \ OUTDIR\fR,\fB \ \-\-outdir \ OUTDIR Output into this directory (or use \- to output mailbox contents to stdout) .TP .BI \-p \ USEPROJECT\fR,\fB \ \-\-use\-project \ USEPROJECT Use a specific project instead of guessing (linux\-mm, linux\-hardening, etc) .TP .B \-c\fP,\fB \-\-check\-newer\-revisions Check if newer patch revisions exist .TP .BI \-n \ WANTNAME\fR,\fB \ \-\-mbox\-name \ WANTNAME Filename to name the mbox file .TP .BI \-m \ LOCALMBOX\fR,\fB \ \-\-use\-local\-mbox \ LOCALMBOX Instead of grabbing a thread from lore, process this mbox file (or use \- for stdin) .TP .B \-M\fP,\fB \-\-save\-as\-maildir Save as maildir (avoids mbox format ambiguities) .TP .B \-C\fP,\fB \-\-no\-cache Do not use local cache .TP .BI \-v \ WANTVER\fR,\fB \ \-\-use\-version \ WANTVER Get a specific version of the patch/series .TP .B \-t\fP,\fB \-\-apply\-cover\-trailers Apply trailers sent to the cover letter to all patches .TP .B \-S\fP,\fB \-\-sloppy\-trailers Apply trailers without email address match checking .TP .B \-T\fP,\fB \-\-no\-add\-trailers Do not add or sort any trailers .TP .B \-s\fP,\fB \-\-add\-my\-sob Add your own signed\-off\-by to every patch .TP .B \-l\fP,\fB \-\-add\-link Add a lore.kernel.org/r/ link to every patch .TP .B \-Q\fP,\fB \-\-quilt\-ready Save patches in a quilt\-ready folder .TP .BI \-P \ CHERRYPICK\fR,\fB \ \-\-cherry\-pick \ CHERRYPICK Cherry\-pick a subset of patches (e.g. "\-P 1\-2,4,6\-", "\-P _" to use just the msgid specified, or "\-P *globbing*" to match on commit subject) .TP .B \-g\fP,\fB \-\-guess\-base Try to guess the base of the series (if not specified) .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 \-\-cc\-trailers Copy all Cc\(aqd addresses into Cc: trailers, if not already present .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 attest .sp usage: b4 attest [\-h] patchfile [patchfile ...] .INDENT 0.0 .TP .B positional arguments: patchfile Patches to attest .UNINDENT .sp \fIExample\fP: b4 attest outgoing/*.patch .SS b4 pr .INDENT 0.0 .TP .B usage: command.py 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 .B \-l\fP,\fB \-\-retrieve\-links Attempt to retrieve any Link: URLs (use with \-e) .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] [\-s SEND [SEND ...]] [\-d DISCARD [DISCARD ...]] [\-a] [\-b BRANCH] [\-\-since SINCE] .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 \-s \ SEND\fR,\fB \ \-\-send \ SEND Generate thankyous for specific entries from \-l (e.g.: 1,3\-5,7\-; or "all") .TP .BI \-d \ DISCARD\fR,\fB \ \-\-discard \ DISCARD Discard specific messages from \-l (e.g.: 1,3\-5,7\-; or "all") .TP .B \-a\fP,\fB \-\-auto Use the Auto\-Thankanator to figure out what got applied/merged .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) .UNINDENT .UNINDENT .sp \fIExample\fP: b4 ty \-\-auto .SS b4 diff .sp usage: b4 diff [\-h] [\-g GITDIR] [\-p USEPROJECT] [\-C] [\-v WANTVERS [WANTVERS ...]] [\-n] [\-o OUTDIFF] [\-c] [\-m AMBOX AMBOX] [msgid] .INDENT 0.0 .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 "b4 am" .UNINDENT .UNINDENT .UNINDENT .sp \fIExample\fP: b4 diff \fI\%20200526205322.23465\-1\-mic@digikod.net\fP .SS b4 kr .sp usage: b4 kr [\-h] [\-p USEPROJECT] [\-m LOCALMBOX] [\-C] [\-\-show\-keys] [msgid] .INDENT 0.0 .TP .B 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 .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 .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 Default configuration, with explanations: .INDENT 0.0 .INDENT 3.5 .sp .nf .ft C [b4] # Where to look up threads by message id midmask = https://lore.kernel.org/r/%s # # When recording Link: trailers, use this mask linkmask = https://lore.kernel.org/r/%s # # When duplicate messages exist, use the following order to decide # which list\-id is likely to have the least mangled version. Default # preference is listed below, in the order of lists most likely to # preserve proper DKIM validation. Use shell\-style globbing and # separate multiple entries with commas. Must end with ,* listid\-preference = *.feeds.kernel.org,*.linux.dev,*.kernel.org,* # # Set to "yes" to save maildirs instead of mailboxes # This will help avoid mboxo/mboxrd format inconsistencies between # public\-inbox, python, and git save\-maildirs = no # # When processing thread trailers, sort them in this order. # Can use shell\-globbing and must end with ,* # Some sorting orders: #trailer\-order=link*,fixes*,cc*,reported*,suggested*,original*,co\-*,tested*,reviewed*,acked*,signed\-off*,* #trailer\-order = fixes*,reported*,suggested*,original*,co\-*,signed\-off*,tested*,reviewed*,acked*,cc*,link*,* trailer\-order = _preserve_ # # Attestation\-checking configuration parameters # off: do not bother checking attestation # check: print an attaboy when attestation is found # softfail: print a warning when no attestation found # hardfail: exit with an error when no attestation found attestation\-policy = softfail # # Perform DKIM attestation? attestation\-check\-dkim = yes # # When showing attestation check results, do you like "fancy" (color, unicode) # or simple markers? attestation\-checkmarks = fancy # # How long before we consider attestation to be too old? attestation\-staleness\-days = 30 # # You can point this at a non\-default home dir, if you like, or leave out to # use the OS default. attestation\-gnupghome = None # # If this is not set, we\(aqll use what we find in # git\-config for gpg.program; and if that\(aqs not set, # we\(aqll use "gpg" and hope for the best gpgbin = None # # How long to keep downloaded threads in cache (minutes)? cache\-expire = 10 # 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 # See this page for more info on convenient git.kernel.org shorterners: # https://korg.wiki.kernel.org/userdoc/git\-url\-shorterners thanks\-commit\-url\-mask = None # See thanks\-pr\-template.example. If not set, a default template will be used. thanks\-pr\-template = None # See thanks\-am\-template.example. If not set, a default template will be used. thanks\-am\-template = None .ft P .fi .UNINDENT .UNINDENT .SH SUPPORT .sp Please email \fI\%tools@linux.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.8.0/man/b4.5.rst000066400000000000000000000305721411367610500140140ustar00rootroot00000000000000B4 == ---------------------------------------------------- Work with code submissions in a public-inbox archive ---------------------------------------------------- :Author: mricon@kernel.org :Date: 2021-09-01 :Copyright: The Linux Foundation and contributors :License: GPLv2+ :Version: 0.8.0 :Manual section: 5 SYNOPSIS -------- b4 {mbox,am,attest,pr,ty,diff} [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. SUBCOMMANDS ----------- * *b4 mbox*: Download a thread as an mbox file * *b4 am*: Create an mbox file that is ready to git-am * *b4 pr*: Work with pull requests * *b4 diff*: Show range-diff style diffs between patch versions * *b4 ty*: Create templated replies for processed patches and pull requests * *b4 attest*: (EXPERIMENTAL) Add cryptographic attestation to patches * *b4 kr* (EXPERIMENTAL) Operate on patatt-compatible keyrings 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) SUBCOMMAND OPTIONS ------------------ b4 mbox ~~~~~~~ usage: b4 mbox [-h] [-o OUTDIR] [-p USEPROJECT] [-c] [-n WANTNAME] [-m LOCALMBOX] [msgid] positional arguments: msgid Message ID to process, or pipe a raw message optional arguments: -h, --help show this help message and exit -o OUTDIR, --outdir OUTDIR Output into this directory (or use - to output mailbox contents to stdout) -p USEPROJECT, --use-project USEPROJECT Use a specific project instead of guessing (linux-mm, linux-hardening, etc) -c, --check-newer-revisions Check if newer patch revisions exist -n WANTNAME, --mbox-name WANTNAME Filename to name the mbox file -m LOCALMBOX, --use-local-mbox LOCALMBOX Instead of grabbing a thread from lore, process this mbox file (or use - for stdin) -C, --no-cache Do not use local cache -f, --filter-dupes When adding messages to existing maildir, filter out duplicates -M, --save-as-maildir Save as maildir (avoids mbox format ambiguities) *Example*: b4 mbox 20200313231252.64999-1-keescook@chromium.org b4 am ~~~~~ usage: b4 am [-h] [-o OUTDIR] [-p USEPROJECT] [-c] [-n WANTNAME] [-m LOCALMBOX] [-v WANTVER] [-t] [-T] [-s] [-l] [-Q] [msgid] positional arguments: msgid Message ID to process, or pipe a raw message optional arguments: -h, --help show this help message and exit -o OUTDIR, --outdir OUTDIR Output into this directory (or use - to output mailbox contents to stdout) -p USEPROJECT, --use-project USEPROJECT Use a specific project instead of guessing (linux-mm, linux-hardening, etc) -c, --check-newer-revisions Check if newer patch revisions exist -n WANTNAME, --mbox-name WANTNAME Filename to name the mbox file -m LOCALMBOX, --use-local-mbox LOCALMBOX Instead of grabbing a thread from lore, process this mbox file (or use - for stdin) -M, --save-as-maildir Save as maildir (avoids mbox format ambiguities) -C, --no-cache Do not use local cache -v WANTVER, --use-version WANTVER Get a specific version of the patch/series -t, --apply-cover-trailers Apply trailers sent to the cover letter to all patches -S, --sloppy-trailers Apply trailers without email address match checking -T, --no-add-trailers Do not add or sort any trailers -s, --add-my-sob Add your own signed-off-by to every patch -l, --add-link Add a lore.kernel.org/r/ link to every patch -Q, --quilt-ready Save patches in a quilt-ready folder -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) -g, --guess-base Try to guess the base of the series (if not specified) -3, --prep-3way Prepare for a 3-way merge (tries to ensure that all index blobs exist by making a fake commit range) --cc-trailers Copy all Cc'd addresses into Cc: trailers, if not already present --no-cover Do not save the cover letter (on by default when using -o -) --no-partial-reroll Do not reroll partial series when detected *Example*: b4 am 20200313231252.64999-1-keescook@chromium.org b4 attest ~~~~~~~~~ usage: b4 attest [-h] patchfile [patchfile ...] positional arguments: patchfile Patches to attest *Example*: b4 attest outgoing/\*.patch b4 pr ~~~~~ usage: command.py 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) -l, --retrieve-links Attempt to retrieve any Link: URLs (use with -e) -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] [-s SEND [SEND ...]] [-d DISCARD [DISCARD ...]] [-a] [-b BRANCH] [--since SINCE] 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 -s SEND, --send SEND Generate thankyous for specific entries from -l (e.g.: 1,3-5,7-; or "all") -d DISCARD, --discard DISCARD Discard specific messages from -l (e.g.: 1,3-5,7-; or "all") -a, --auto Use the Auto-Thankanator to figure out what got applied/merged -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) *Example*: b4 ty --auto 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) -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 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. Default configuration, with explanations:: [b4] # Where to look up threads by message id midmask = https://lore.kernel.org/r/%s # # When recording Link: trailers, use this mask linkmask = https://lore.kernel.org/r/%s # # When duplicate messages exist, use the following order to decide # which list-id is likely to have the least mangled version. Default # preference is listed below, in the order of lists most likely to # preserve proper DKIM validation. Use shell-style globbing and # separate multiple entries with commas. Must end with ,* listid-preference = *.feeds.kernel.org,*.linux.dev,*.kernel.org,* # # Set to "yes" to save maildirs instead of mailboxes # This will help avoid mboxo/mboxrd format inconsistencies between # public-inbox, python, and git save-maildirs = no # # When processing thread trailers, sort them in this order. # Can use shell-globbing and must end with ,* # Some sorting orders: #trailer-order=link*,fixes*,cc*,reported*,suggested*,original*,co-*,tested*,reviewed*,acked*,signed-off*,* #trailer-order = fixes*,reported*,suggested*,original*,co-*,signed-off*,tested*,reviewed*,acked*,cc*,link*,* trailer-order = _preserve_ # # Attestation-checking configuration parameters # off: do not bother checking attestation # check: print an attaboy when attestation is found # softfail: print a warning when no attestation found # hardfail: exit with an error when no attestation found attestation-policy = softfail # # Perform DKIM attestation? attestation-check-dkim = yes # # When showing attestation check results, do you like "fancy" (color, unicode) # or simple markers? attestation-checkmarks = fancy # # How long before we consider attestation to be too old? attestation-staleness-days = 30 # # You can point this at a non-default home dir, if you like, or leave out to # use the OS default. attestation-gnupghome = 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 best gpgbin = None # # How long to keep downloaded threads in cache (minutes)? cache-expire = 10 # 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 # See this page for more info on convenient git.kernel.org shorterners: # https://korg.wiki.kernel.org/userdoc/git-url-shorterners thanks-commit-url-mask = None # See thanks-pr-template.example. If not set, a default template will be used. thanks-pr-template = None # See thanks-am-template.example. If not set, a default template will be used. thanks-am-template = None SUPPORT ------- Please email tools@linux.kernel.org with support requests, or browse the list archive at https://lore.kernel.org/tools. b4-0.8.0/patatt/000077500000000000000000000000001411367610500133255ustar00rootroot00000000000000b4-0.8.0/requirements.txt000066400000000000000000000001671411367610500153200ustar00rootroot00000000000000requests~=2.25.0 # These are optional, needed for attestation features dnspython~=2.1.0 dkimpy~=1.0.5 patatt>=0.4,<2.0 b4-0.8.0/setup.py000066400000000000000000000027751411367610500135550ustar00rootroot00000000000000#!/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', 'lore.kernel.org', 'patches'], install_requires=[ 'requests~=2.24', 'dkimpy~=1.0', 'dnspython~=2.0', 'patatt>=0.4,<2.0', ], python_requires='>=3.6', entry_points={ 'console_scripts': [ 'b4=b4.command:cmd' ], }, ) b4-0.8.0/tests/000077500000000000000000000000001411367610500131725ustar00rootroot00000000000000b4-0.8.0/tests/__init__.py000066400000000000000000000000001411367610500152710ustar00rootroot00000000000000b4-0.8.0/tests/samples/000077500000000000000000000000001411367610500146365ustar00rootroot00000000000000b4-0.8.0/tests/samples/gpg-badsig.txt000066400000000000000000000005511411367610500174040ustar00rootroot00000000000000[GNUPG:] NEWSIG [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] KEYEXPIRED 1446574742 [GNUPG:] KEYEXPIRED 1525881230 [GNUPG:] KEY_CONSIDERED DE0E66E32F1FDD0902666B96E63EDCA9329DD07E 0 [GNUPG:] BADSIG B6C41CE35664996C Konstantin Ryabitsev b4-0.8.0/tests/samples/gpg-good-invalid-notrust.txt000066400000000000000000000015171411367610500222460ustar00rootroot00000000000000[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.8.0/tests/samples/gpg-good-valid-notrust.txt000066400000000000000000000017271411367610500217220ustar00rootroot00000000000000[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.8.0/tests/samples/gpg-good-valid-trusted.txt000066400000000000000000000017261411367610500216750ustar00rootroot00000000000000[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.8.0/tests/samples/gpg-no-pubkey.txt000066400000000000000000000002231411367610500200600ustar00rootroot00000000000000[GNUPG:] NEWSIG [GNUPG:] ERRSIG B6C41CE35664996C 22 8 01 1623274836 9 76BE5DB25271E1481E678C35B6C41CE35664996C [GNUPG:] NO_PUBKEY B6C41CE35664996C b4-0.8.0/tests/test___init__.py000066400000000000000000000033241411367610500163440ustar00rootroot00000000000000import pytest # noqa import b4 import re import os @pytest.mark.parametrize('source,expected', [ ('good-valid-trusted', (True, True, True, 'B6C41CE35664996C', '1623274836')), ('good-valid-notrust', (True, True, False, 'B6C41CE35664996C', '1623274836')), ('good-invalid-notrust', (True, False, False, 'B6C41CE35664996C', None)), ('badsig', (False, False, False, 'B6C41CE35664996C', None)), ('no-pubkey', (False, False, False, None, None)), ]) def test_check_gpg_status(source, expected): with open(f'tests/samples/gpg-{source}.txt', 'r') as fh: status = fh.read() assert b4.check_gpg_status(status) == expected @pytest.mark.parametrize('source,regex,flags,ismbox', [ (None, r'^From git@z ', 0, False), (None, r'\n\nFrom git@z ', 0, False), ]) def test_save_git_am_mbox(tmpdir, source, regex, flags, ismbox): import re if source is not None: if ismbox: import mailbox mbx = mailbox.mbox(f'tests/samples/{source}.txt') msgs = list(mbx) else: import email with open(f'tests/samples/{source}.txt', 'rb') as fh: msg = email.message_from_binary_file(fh) msgs = [msg] else: import email.message msgs = list() for x in range(0, 3): msg = email.message.EmailMessage() msg.set_payload(f'Hello world {x}\n') msg['Subject'] = f'Hello world {x}' msg['From'] = f'Me{x} ' msgs.append(msg) dest = os.path.join(tmpdir, 'out') with open(dest, 'w') 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) b4-0.8.0/thanks-am-template.example000066400000000000000000000026251411367610500171060ustar00rootroot00000000000000# 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.8.0/thanks-pr-template.example000066400000000000000000000025731411367610500171340ustar00rootroot00000000000000# 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}