[PATCH b4 v2 04/11] Add ruff format check to CI
Tamir Duberstein <[email protected]>
| Newsgroups | org.kernel.linux.tools |
|---|---|
| Message-ID | <[email protected]> |
Enable ruff format checking in the b4 CI script and configure Ruff's formatter in pyproject.toml. Apply a one-time repo-wide format pass so the new check enforces the current style without leaving the branch permanently red. Signed-off-by: Tamir Duberstein <[email protected]> --- ci.sh | 1 + misc/retrieve_lore_thread.py | 2 +- misc/review-ci-example.py | 4 +- misc/send-receive.py | 256 ++++-- pyproject.toml | 3 + src/b4/__init__.py | 1333 +++++++++++++++++++++-------- src/b4/bugs/__init__.py | 44 +- src/b4/bugs/_import.py | 14 +- src/b4/bugs/_tui.py | 433 ++++++---- src/b4/command.py | 1354 ++++++++++++++++++++++------- src/b4/diff.py | 34 +- src/b4/dig.py | 58 +- src/b4/ez.py | 908 ++++++++++++++------ src/b4/kr.py | 8 +- src/b4/mbox.py | 274 ++++-- src/b4/pr.py | 127 ++- src/b4/review/__init__.py | 23 +- src/b4/review/_review.py | 485 +++++++---- src/b4/review/checks.py | 379 +++++---- src/b4/review/messages.py | 63 +- src/b4/review/tracking.py | 646 ++++++++------ src/b4/review_tui/__init__.py | 18 +- src/b4/review_tui/_common.py | 173 ++-- src/b4/review_tui/_entry.py | 66 +- src/b4/review_tui/_lite_app.py | 92 +- src/b4/review_tui/_modals.py | 612 +++++++++----- src/b4/review_tui/_pw_app.py | 173 ++-- src/b4/review_tui/_review_app.py | 426 +++++++--- src/b4/review_tui/_tracking_app.py | 1474 +++++++++++++++++++++----------- src/b4/tui/__init__.py | 1 + src/b4/tui/_common.py | 19 +- src/b4/tui/_modals.py | 38 +- src/b4/ty.py | 187 +++-- src/tests/conftest.py | 10 +- src/tests/test___init__.py | 799 ++++++++++++------ src/tests/test_ez.py | 143 +++- src/tests/test_mbox.py | 85 +- src/tests/test_messages.py | 21 +- src/tests/test_patatt.py | 30 +- src/tests/test_rethread.py | 174 ++-- src/tests/test_review.py | 1636 +++++++++++++++++++++--------------- src/tests/test_review_checks.py | 454 ++++++---- src/tests/test_review_show_info.py | 92 +- src/tests/test_review_tracking.py | 982 ++++++++++++++-------- src/tests/test_three_way_merge.py | 181 ++-- src/tests/test_tui_bugs.py | 38 +- src/tests/test_tui_modals.py | 44 +- src/tests/test_tui_review.py | 94 +-- src/tests/test_tui_tracking.py | 1332 ++++++++++++++++++----------- 49 files changed, 10637 insertions(+), 5206 deletions(-) diff --git a/ci.sh b/ci.sh index b65ae97..ddd4cff 100755 --- a/ci.sh +++ b/ci.sh @@ -2,5 +2,6 @@ set -eu +uv run ruff format --check uv run ruff check uv run mypy . diff --git a/misc/retrieve_lore_thread.py b/misc/retrieve_lore_thread.py index 4de39fb..aad586b 100644 --- a/misc/retrieve_lore_thread.py +++ b/misc/retrieve_lore_thread.py @@ -21,7 +21,7 @@ class Function(OpenAISchema): ) class Config: - title = "retrieve_lore_thread" + title = 'retrieve_lore_thread' @classmethod def execute(cls, message_id: str) -> str: diff --git a/misc/review-ci-example.py b/misc/review-ci-example.py index cbac2ae..dee0a5d 100755 --- a/misc/review-ci-example.py +++ b/misc/review-ci-example.py @@ -76,7 +76,9 @@ def main() -> None: if build_status == 'warn': build_result['details'] = 'Warning: unused variable in drivers/foo.c:42' elif build_status == 'fail': - build_result['details'] = 'Error: implicit declaration of function bar\n drivers/foo.c:57:5' + build_result['details'] = ( + 'Error: implicit declaration of function bar\n drivers/foo.c:57:5' + ) results.append(build_result) # Simulate a test suite check diff --git a/misc/send-receive.py b/misc/send-receive.py index 35c5e99..22a5d99 100644 --- a/misc/send-receive.py +++ b/misc/send-receive.py @@ -36,7 +36,6 @@ logger.setLevel(logging.DEBUG) class SendReceiveListener(object): - def __init__(self, _engine, _config) -> None: self._engine = _engine self._config = _config @@ -51,7 +50,9 @@ class SendReceiveListener(object): def _init_logger(self, logfile: str, loglevel: str) -> None: global logger lch = logging.handlers.WatchedFileHandler(os.path.expanduser(logfile)) - lfmt = logging.Formatter('[%(process)d] %(asctime)s - %(levelname)s - %(message)s') + lfmt = logging.Formatter( + '[%(process)d] %(asctime)s - %(levelname)s - %(message)s' + ) lch.setFormatter(lfmt) if loglevel == 'critical': lch.setLevel(logging.CRITICAL) @@ -65,18 +66,23 @@ class SendReceiveListener(object): logger.info('Setting up SQLite database') conn = self._engine.connect() md = sa.MetaData() - meta = sa.Table('meta', md, - sa.Column('version', sa.Integer()) - ) - auth = sa.Table('auth', md, - sa.Column('auth_id', sa.Integer(), primary_key=True), - sa.Column('created', sa.DateTime(), nullable=False, server_default=sa.sql.func.now()), - sa.Column('identity', sa.Text(), nullable=False), - sa.Column('selector', sa.Text(), nullable=False), - sa.Column('pubkey', sa.Text(), nullable=False), - sa.Column('challenge', sa.Text(), nullable=True), - sa.Column('verified', sa.Integer(), nullable=False), - ) + meta = sa.Table('meta', md, sa.Column('version', sa.Integer())) + auth = sa.Table( + 'auth', + md, + sa.Column('auth_id', sa.Integer(), primary_key=True), + sa.Column( + 'created', + sa.DateTime(), + nullable=False, + server_default=sa.sql.func.now(), + ), + sa.Column('identity', sa.Text(), nullable=False), + sa.Column('selector', sa.Text(), nullable=False), + sa.Column('pubkey', sa.Text(), nullable=False), + sa.Column('challenge', sa.Text(), nullable=True), + sa.Column('verified', sa.Integer(), nullable=False), + ) sa.Index('idx_identity_selector', auth.c.identity, auth.c.selector, unique=True) md.create_all(self._engine) q = sa.insert(meta).values(version=DB_VERSION) @@ -98,7 +104,9 @@ class SendReceiveListener(object): logger.debug('Returning success: %s', message) resp.text = json.dumps({'result': 'success', 'message': message}) - def get_smtp(self) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, None], Tuple[str, str]]: + def get_smtp( + self, + ) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, None], Tuple[str, str]]: sconfig = self._config['sendemail'] server = sconfig.get('smtpserver', 'localhost') port = sconfig.get('smtpserverport', 0) @@ -120,7 +128,9 @@ class SendReceiveListener(object): # We do TLS from the get-go smtp = smtplib.SMTP_SSL(server, port) else: - raise smtplib.SMTPException('Unclear what to do with smtpencryption=%s' % encryption) + raise smtplib.SMTPException( + 'Unclear what to do with smtpencryption=%s' % encryption + ) # If we got to this point, we should do authentication. auser = sconfig.get('smtpuser') @@ -144,21 +154,35 @@ class SendReceiveListener(object): logger.info('New authentication request for %s/%s', identity, selector) pubkey = jdata.get('pubkey') t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine) - q = sa.select([t_auth.c.auth_id]).where(t_auth.c.identity == identity, t_auth.c.selector == selector, - t_auth.c.verified == 1) + q = sa.select([t_auth.c.auth_id]).where( + t_auth.c.identity == identity, + t_auth.c.selector == selector, + t_auth.c.verified == 1, + ) rp = conn.execute(q) if len(rp.fetchall()): - self.send_error(resp, message='i=%s;s=%s is already authorized' % (identity, selector)) + self.send_error( + resp, message='i=%s;s=%s is already authorized' % (identity, selector) + ) return # delete any existing challenges for this and create a new one - q = sa.delete(t_auth).where(t_auth.c.identity == identity, t_auth.c.selector == selector, - t_auth.c.verified == 0) + q = sa.delete(t_auth).where( + t_auth.c.identity == identity, + t_auth.c.selector == selector, + t_auth.c.verified == 0, + ) conn.execute(q) # create new challenge import uuid + cstr = str(uuid.uuid4()) - q = sa.insert(t_auth).values(identity=identity, selector=selector, pubkey=pubkey, challenge=cstr, - verified=0) + q = sa.insert(t_auth).values( + identity=identity, + selector=selector, + pubkey=pubkey, + challenge=cstr, + verified=0, + ) conn.execute(q) logger.info('Created new challenge for %s/%s: %s', identity, selector, cstr) conn.close() @@ -172,7 +196,9 @@ class SendReceiveListener(object): tpt_subject = self._config['templates']['verify-subject'].strip() tpt_body = self._config['templates']['verify-body'].strip() signature = self._config['templates']['signature'].strip() - subject = Template(tpt_subject).safe_substitute({'identity': jdata.get('identity')}) + subject = Template(tpt_subject).safe_substitute( + {'identity': jdata.get('identity')} + ) cmsg.add_header('Subject', subject) name = jdata.get('name', 'Anonymous Llama') cmsg.add_header('To', f'{name} <{identity}>') @@ -215,9 +241,11 @@ class SendReceiveListener(object): if s: selector = s.decode() logger.debug('i=%s; s=%s', identity, selector) - q = sa.select([t_auth.c.auth_id, t_auth.c.pubkey]).where(t_auth.c.identity == identity, - t_auth.c.selector == selector, - t_auth.c.verified == verified) + q = sa.select([t_auth.c.auth_id, t_auth.c.pubkey]).where( + t_auth.c.identity == identity, + t_auth.c.selector == selector, + t_auth.c.verified == verified, + ) rp = conn.execute(q) res = rp.fetchall() if res: @@ -228,7 +256,9 @@ class SendReceiveListener(object): logger.debug('Did not find a matching identity!') raise patatt.NoKeyError('No match for this identity') - logger.debug('Found matching %s/%s with auth_id=%s', identity, selector, auth_id) + logger.debug( + 'Found matching %s/%s with auth_id=%s', identity, selector, auth_id + ) pm.validate(identity, pubkey.encode()) return identity, selector, auth_id @@ -243,11 +273,18 @@ class SendReceiveListener(object): t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine) bdata = msg.encode() try: - identity, selector, auth_id = self.validate_message(conn, t_auth, bdata, verified=0) + identity, selector, auth_id = self.validate_message( + conn, t_auth, bdata, verified=0 + ) except Exception as ex: self.send_error(resp, message='Signature validation failed: %s' % ex) return - logger.debug('Message validation passed for %s/%s with auth_id=%s', identity, selector, auth_id) + logger.debug( + 'Message validation passed for %s/%s with auth_id=%s', + identity, + selector, + auth_id, + ) # Now compare the challenge to what we received q = sa.select([t_auth.c.challenge]).where(t_auth.c.auth_id == auth_id) @@ -255,13 +292,28 @@ class SendReceiveListener(object): res = rp.fetchall() challenge = res[0][0] if msg.find(f'\nverify:{challenge}') < 0: - self.send_error(resp, message='Challenge verification for %s/%s did not match' % (identity, selector)) + self.send_error( + resp, + message='Challenge verification for %s/%s did not match' + % (identity, selector), + ) return - logger.info('Successfully verified challenge for %s/%s with auth_id=%s', identity, selector, auth_id) - q = sa.update(t_auth).where(t_auth.c.auth_id == auth_id).values(challenge=None, verified=1) + logger.info( + 'Successfully verified challenge for %s/%s with auth_id=%s', + identity, + selector, + auth_id, + ) + q = ( + sa.update(t_auth) + .where(t_auth.c.auth_id == auth_id) + .values(challenge=None, verified=1) + ) conn.execute(q) conn.close() - self.send_success(resp, message='Challenge verified for %s/%s' % (identity, selector)) + self.send_success( + resp, message='Challenge verified for %s/%s' % (identity, selector) + ) def auth_delete(self, jdata, resp) -> None: msg = jdata.get('msg') @@ -278,11 +330,15 @@ class SendReceiveListener(object): self.send_error(resp, message='Signature validation failed: %s' % ex) return - logger.info('Deleting record for %s/%s with auth_id=%s', identity, selector, auth_id) + logger.info( + 'Deleting record for %s/%s with auth_id=%s', identity, selector, auth_id + ) q = sa.delete(t_auth).where(t_auth.c.auth_id == auth_id) conn.execute(q) conn.close() - self.send_success(resp, message='Record deleted for %s/%s' % (identity, selector)) + self.send_success( + resp, message='Record deleted for %s/%s' % (identity, selector) + ) def clean_header(self, hdrval: str) -> str: if hdrval is None: @@ -312,7 +368,11 @@ class SendReceiveListener(object): # Remove any quoted-printable header junk from the name pair = (self.clean_header(pair[0]), pair[1]) # Work around https://github.com/python/cpython/issues/100900 - if not pair[0].startswith('=?') and not pair[0].startswith('"') and qspecials.search(pair[0]): + if ( + not pair[0].startswith('=?') + and not pair[0].startswith('"') + and qspecials.search(pair[0]) + ): quoted = email.utils.quote(pair[0]) addrs.append(f'"{quoted}" <{pair[1]}>') continue @@ -325,14 +385,21 @@ class SendReceiveListener(object): except AttributeError: return all([ord(c) < 128 for c in strval]) - def wrap_header(self, hdr, width: int = 75, nl: str = '\r\n', transform: str = 'preserve') -> bytes: + def wrap_header( + self, hdr, width: int = 75, nl: str = '\r\n', transform: str = 'preserve' + ) -> bytes: hname, hval = hdr if hname.lower() in ('to', 'cc', 'from', 'x-original-from'): _parts = [f'{hname}: '] first = True for addr in email.utils.getaddresses([hval]): if transform == 'encode' and not self.isascii(addr[0]): - addr = (email.quoprimime.header_encode(addr[0].encode(), charset='utf-8'), addr[1]) + addr = ( + email.quoprimime.header_encode( + addr[0].encode(), charset='utf-8' + ), + addr[1], + ) qp = self.format_addrs([addr], clean=False) elif transform == 'decode': qp = self.format_addrs([addr], clean=True) @@ -359,11 +426,18 @@ class SendReceiveListener(object): # Use simple textwrap, with a small trick that ensures that long non-breakable # strings don't show up on the next line from the bare header hdata = hdata.replace(': ', ':_', 1) - wrapped = textwrap.wrap(hdata, break_long_words=False, break_on_hyphens=False, - subsequent_indent=' ', width=width) + wrapped = textwrap.wrap( + hdata, + break_long_words=False, + break_on_hyphens=False, + subsequent_indent=' ', + width=width, + ) return nl.join(wrapped).replace(':_', ': ', 1).encode() - qp = f'{hname}: ' + email.quoprimime.header_encode(hval.encode(), charset='utf-8') + qp = f'{hname}: ' + email.quoprimime.header_encode( + hval.encode(), charset='utf-8' + ) # is it longer than width? if len(qp) <= width: return qp.encode() @@ -375,17 +449,22 @@ class SendReceiveListener(object): # Also allow for the ' ' at the front on continuation lines wrapat -= 1 # Make sure we don't break on a =XX escape sequence - while '=' in qp[wrapat - 2:wrapat]: + while '=' in qp[wrapat - 2 : wrapat]: wrapat -= 1 _parts.append(qp[:wrapat] + '?=') - qp = ('=?utf-8?q?' + qp[wrapat:]) + qp = '=?utf-8?q?' + qp[wrapat:] _parts.append(qp) return f'{nl} '.join(_parts).encode() - def get_msg_as_bytes(self, msg: email.message.Message, nl: str = '\r\n', headers: str = 'preserve') -> bytes: + def get_msg_as_bytes( + self, msg: email.message.Message, nl: str = '\r\n', headers: str = 'preserve' + ) -> bytes: bdata = b'' for hname, hval in msg.items(): - bdata += self.wrap_header((hname, str(hval)), nl=nl, transform=headers) + nl.encode() + bdata += ( + self.wrap_header((hname, str(hval)), nl=nl, transform=headers) + + nl.encode() + ) bdata += nl.encode() payload = msg.get_payload(decode=True) for bline in payload.split(b'\n'): @@ -402,8 +481,13 @@ class SendReceiveListener(object): return logger.debug('Received a request for %s messages', len(umsgs)) - diffre = re.compile(rb'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I) - diffstatre = re.compile(rb'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.M | re.I) + diffre = re.compile( + rb'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', + flags=re.M | re.I, + ) + diffstatre = re.compile( + rb'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.M | re.I + ) msgs = list() conn = self._engine.connect() @@ -417,7 +501,9 @@ class SendReceiveListener(object): try: identity, selector, auth_id = self.validate_message(conn, t_auth, bdata) except patatt.NoKeyError: - self.send_error(resp, message='No matching key, please complete web auth first.') + self.send_error( + resp, message='No matching key, please complete web auth first.' + ) return except Exception as ex: self.send_error(resp, message='Signature validation failed: %s' % ex) @@ -427,7 +513,10 @@ class SendReceiveListener(object): if seenid is None: seenid = auth_id elif seenid != auth_id: - self.send_error(resp, message='We only support a single signing identity across patch series.') + self.send_error( + resp, + message='We only support a single signing identity across patch series.', + ) return msg = email.message_from_bytes(bdata, policy=emlpolicy) @@ -454,8 +543,15 @@ class SendReceiveListener(object): return # Make sure that From, Date, Subject, and Message-Id headers exist - if not msg.get('From') or not msg.get('Date') or not msg.get('Subject') or not msg.get('Message-Id'): - self.send_error(resp, message='Message is missing some required headers.') + if ( + not msg.get('From') + or not msg.get('Date') + or not msg.get('Subject') + or not msg.get('Message-Id') + ): + self.send_error( + resp, message='Message is missing some required headers.' + ) return # Make sure that From: matches the validated identity. We allow + expansion, @@ -463,19 +559,26 @@ class SendReceiveListener(object): allfroms = utils.getaddresses([str(x) for x in msg.get_all('from')]) # Allow only a single From: address if len(allfroms) > 1: - self.send_error(resp, message='Message may only contain a single From: address.') + self.send_error( + resp, message='Message may only contain a single From: address.' + ) return fromaddr = allfroms[0][1] if validfrom != fromaddr: ldparts = fromaddr.split('@') if len(ldparts) != 2: - self.send_error(resp, message=f'Invalid address in From: {fromaddr}') + self.send_error( + resp, message=f'Invalid address in From: {fromaddr}' + ) return lparts = ldparts[0].split('+', maxsplit=1) toval = f'{lparts[0]}@{ldparts[1]}' if toval != identity: - self.send_error(resp, message=f'From header invalid for identity {identity}: {fromaddr}') + self.send_error( + resp, + message=f'From header invalid for identity {identity}: {fromaddr}', + ) return # usually, all From: addresses will be the same, so use validfrom as a quick bypass if validfrom is None: @@ -492,9 +595,15 @@ class SendReceiveListener(object): matched = True break if not matched: - self.send_error(resp, message='Destinations must include a mailing list we recognize.') + self.send_error( + resp, + message='Destinations must include a mailing list we recognize.', + ) return - msg.add_header('X-Endpoint-Received', f'by {servicename} for {identity}/{selector} with auth_id={auth_id}') + msg.add_header( + 'X-Endpoint-Received', + f'by {servicename} for {identity}/{selector} with auth_id={auth_id}', + ) msgs.append((msg, destaddrs)) conn.close() @@ -512,7 +621,11 @@ class SendReceiveListener(object): bccaddrs.update([x[1] for x in utils.getaddresses([_bcc])]) repo = listid = None - if 'public-inbox' in self._config and self._config['public-inbox'].get('repo') and not reflect: + if ( + 'public-inbox' in self._config + and self._config['public-inbox'].get('repo') + and not reflect + ): repo = self._config['public-inbox'].get('repo') listid = self._config['public-inbox'].get('listid') if not os.path.isdir(repo): @@ -549,7 +662,9 @@ class SendReceiveListener(object): logger.debug('%s matches mydomain, no substitution required', origaddr) fromaddr = origaddr else: - logger.debug('%s does not match mydomain, substitution required', origaddr) + logger.debug( + '%s does not match mydomain, substitution required', origaddr + ) # We can't just send this as-is due to DMARC policies. Therefore, we set # Reply-To and X-Original-From. fromaddr = frompair[1] @@ -580,13 +695,21 @@ class SendReceiveListener(object): if cmsg.get('From') is None: newbody = 'From: ' + self.clean_header(origfrom) + '\n' if cmsg.get('Subject'): - newbody += 'Subject: ' + self.clean_header(cmsg.get('Subject')) + '\n' + newbody += ( + 'Subject: ' + + self.clean_header(cmsg.get('Subject')) + + '\n' + ) if cmsg.get('Date'): - newbody += 'Date: ' + self.clean_header(cmsg.get('Date')) + '\n' + newbody += ( + 'Date: ' + self.clean_header(cmsg.get('Date')) + '\n' + ) newbody += '\n' + body.decode() msg.set_payload(newbody, charset='utf-8') # If we have non-ascii content in the new body, force CTE to 8bit - if msg['Content-Transfer-Encoding'] == '7bit' and not all(ord(char) < 128 for char in newbody): + if msg['Content-Transfer-Encoding'] == '7bit' and not all( + ord(char) < 128 for char in newbody + ): msg.set_charset('utf-8') msg.replace_header('Content-Transfer-Encoding', '8bit') @@ -610,8 +733,12 @@ class SendReceiveListener(object): # run it once after writing all messages logger.debug('Running public-inbox repo hook (if present)') ezpi.run_hook(repo) - logger.info('%s %s messages for %s/%s', sentaction, len(msgs), identity, selector) - self.send_success(resp, message=f'{sentaction} {len(msgs)} messages for {identity}/{selector}') + logger.info( + '%s %s messages for %s/%s', sentaction, len(msgs), identity, selector + ) + self.send_success( + resp, message=f'{sentaction} {len(msgs)} messages for {identity}/{selector}' + ) def on_post(self, req, resp): if not req.content_length: @@ -677,6 +804,7 @@ app.add_route(mp, srl) if __name__ == '__main__': from wsgiref.simple_server import make_server + logger.setLevel(logging.DEBUG) ch = logging.StreamHandler() formatter = logging.Formatter('%(message)s') diff --git a/pyproject.toml b/pyproject.toml index 6eb2fbb..0c4f024 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -110,6 +110,9 @@ extend-select = [ ] flake8-quotes.inline-quotes = "single" +[tool.ruff.format] +quote-style = "single" + [tool.pyright] typeCheckingMode = "off" diff --git a/src/b4/__init__.py b/src/b4/__init__.py index df5c58c..3c1c127 100644 --- a/src/b4/__init__.py +++ b/src/b4/__init__.py @@ -61,7 +61,9 @@ ConfigDictT = Dict[str, Union[str, List[str], None]] charset.add_charset('utf-8', None) # Policy we use for saving mail locally -emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length=None, message_factory=EmailMessage) +emlpolicy = email.policy.EmailPolicy( + utf8=True, cte_type='8bit', max_line_length=None, message_factory=EmailMessage +) # Presence of these characters requires quoting of the name in the header # adapted from email._parseaddr @@ -99,7 +101,9 @@ logging.getLogger('liblore').parent = logger HUNK_RE = re.compile(r'^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@') FILENAME_RE = re.compile(r'^(---|\+\+\+) (\S+)') -DIFF_RE = re.compile(r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I) +DIFF_RE = re.compile( + r'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I +) DIFFSTAT_RE = re.compile(r'^\s*\d+ file.*\d+ (insertion|deletion)', flags=re.M | re.I) ATT_PASS_SIMPLE = 'v' @@ -247,19 +251,32 @@ class LoreMailbox: 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) + 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: + 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) + 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: @@ -301,19 +318,22 @@ class LoreMailbox: if not q: return query = ' OR '.join(q) - qmsgs = get_pi_search_results(query, message='Looking for additional code-review trailers on %s') + qmsgs = get_pi_search_results( + query, message='Looking for additional code-review trailers on %s' + ) if not qmsgs: logger.debug('No matching code-review messages') return logger.debug('Retrieved %s matching code-review messages', len(qmsgs)) - patchid_map = map_codereview_trailers(qmsgs, ignore_msgids=set(self.msgid_map.keys())) + patchid_map = map_codereview_trailers( + qmsgs, ignore_msgids=set(self.msgid_map.keys()) + ) for patchid, fmsgs in patchid_map.items(): if patchid not in self.trailer_map: self.trailer_map[patchid] = list() self.trailer_map[patchid] += fmsgs - def get_latest_revision(self) -> Optional[int]: if not len(self.series): return None @@ -323,9 +343,13 @@ class LoreMailbox: revs.sort(key=lambda r: self.series[r].submission_date or 0) return revs[-1] - - def get_series(self, revision: Optional[int] = None, sloppytrailers: bool = False, - reroll: bool = True, codereview_trailers: bool = True) -> Optional['LoreSeries']: + def get_series( + self, + revision: Optional[int] = None, + sloppytrailers: bool = False, + reroll: bool = True, + codereview_trailers: bool = True, + ) -> Optional['LoreSeries']: if revision is None: if not len(self.series): return None @@ -360,7 +384,11 @@ class LoreMailbox: 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: + 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 @@ -371,7 +399,9 @@ class LoreMailbox: # Do we have any follow-ups? for fmsg in self.followups: - logger.debug('Analyzing follow-up: %s (%s)', fmsg.full_subject, fmsg.fromemail) + 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') @@ -397,7 +427,9 @@ class LoreMailbox: trailers, mismatches = fmsg.get_trailers(sloppy=sloppytrailers) for ltr in mismatches: - lser.trailer_mismatches.add((ltr.name, ltr.value, fmsg.fromname, fmsg.fromemail)) + lser.trailer_mismatches.add( + (ltr.name, ltr.value, fmsg.fromname, fmsg.fromemail) + ) lvl = 1 while True: logger.debug('%sParent: %s', ' ' * lvl, pmsg.full_subject) @@ -437,7 +469,9 @@ class LoreMailbox: for lmsg in lser.patches: if lmsg is None or lmsg.git_patch_id is None: continue - logger.debug(' matching patch_id %s from: %s', lmsg.git_patch_id, lmsg.full_subject) + logger.debug( + ' matching patch_id %s from: %s', lmsg.git_patch_id, lmsg.full_subject + ) if lmsg.git_patch_id in self.trailer_map: for fmsg in self.trailer_map[lmsg.git_patch_id]: logger.debug(' matched: %s', fmsg.msgid) @@ -449,14 +483,18 @@ class LoreMailbox: if fltr in lmsg.followup_trailers: logger.debug(' identical trailer received for this series') continue - logger.debug(' carrying over the trailer to this series (may be duplicate)') + logger.debug( + ' carrying over the trailer to this series (may be duplicate)' + ) logger.debug(' %s', lmsg.full_subject) logger.debug(' + %s', fltr.as_string()) if fltr.lmsg: logger.debug(' via: %s', fltr.lmsg.msgid) lmsg.followup_trailers.append(fltr) for fltr in fmis: - lser.trailer_mismatches.add((fltr.name, fltr.value, fmsg.fromname, fmsg.fromemail)) + lser.trailer_mismatches.add( + (fltr.name, fltr.value, fmsg.fromname, fmsg.fromemail) + ) return lser @@ -497,7 +535,12 @@ class LoreMailbox: 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: + 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 @@ -509,14 +552,29 @@ class LoreMailbox: # 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): + 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): + 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) + 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 @@ -600,7 +658,6 @@ class LoreSeries: return lmsg raise IndexError('No such patch in series') - def add_patch(self, lmsg: 'LoreMessage') -> None: while len(self.patches) < lmsg.expected + 1: self.patches.append(None) @@ -608,7 +665,9 @@ class LoreSeries: omsg = self.patches[lmsg.counter] if omsg is not None: # Okay, strange, is the one in there a reply? - logger.warning('WARNING: duplicate messages found at index %s', lmsg.counter) + logger.warning( + 'WARNING: duplicate messages found at index %s', lmsg.counter + ) logger.warning(' Subject 1: %s', lmsg.subject) logger.warning(' Subject 2: %s', omsg.subject) if omsg.reply or (omsg.counters_inferred and not lmsg.counters_inferred): @@ -628,17 +687,28 @@ class LoreSeries: if lmsg.counter < 2: # Cover letter or first patch if not self.base_commit and '\nbase-commit:' in lmsg.body: - matches = re.search(r'^base-commit: .*?([\da-f]+)', lmsg.body, flags=re.I | re.M) + matches = re.search( + r'^base-commit: .*?([\da-f]+)', lmsg.body, flags=re.I | re.M + ) if matches: self.base_commit = matches.groups()[0] if not self.change_id and '\nchange-id:' in lmsg.body: - matches = re.search(r'^change-id:\s+(\S+)', lmsg.body, flags=re.I | re.M) + matches = re.search( + r'^change-id:\s+(\S+)', lmsg.body, flags=re.I | re.M + ) if matches: self.change_id = matches.groups()[0] if not self.prereq_patch_ids and '\nprerequisite-patch-id:' in lmsg.body: - self.prereq_patch_ids = re.findall(r'^prerequisite-patch-id:\s+(\S+)', lmsg.body, flags=re.I | re.M) - if not self.prereq_base_commit and '\nprerequisite-base-commit:' in lmsg.body: - matches = re.search(r'^prerequisite-base-id:\s+(\S+)', lmsg.body, flags=re.I | re.M) + self.prereq_patch_ids = re.findall( + r'^prerequisite-patch-id:\s+(\S+)', lmsg.body, flags=re.I | re.M + ) + if ( + not self.prereq_base_commit + and '\nprerequisite-base-commit:' in lmsg.body + ): + matches = re.search( + r'^prerequisite-base-id:\s+(\S+)', lmsg.body, flags=re.I | re.M + ) if matches: self.prereq_base_commit = matches.groups()[0] @@ -666,8 +736,9 @@ class LoreSeries: msg['Subject'] = new_subject @staticmethod - def identify_cover_letter(all_msgs: List[EmailMessage], - msgids: List[str]) -> Tuple[Optional[str], List[EmailMessage]]: + def identify_cover_letter( + all_msgs: List[EmailMessage], msgids: List[str] + ) -> Tuple[Optional[str], List[EmailMessage]]: """Identify the cover letter and patch messages among the user-specified msgids. Scans the messages matching the given msgids for one with an explicit @@ -723,8 +794,9 @@ class LoreSeries: LoreSeries.rewrite_subject_counter(msg, i, num_patches) @staticmethod - def rethread_messages(all_msgs: List[EmailMessage], cover_msgid: str, - patch_msgids: Set[str]) -> None: + def rethread_messages( + all_msgs: List[EmailMessage], cover_msgid: str, patch_msgids: Set[str] + ) -> None: """Rewrite threading headers so all top-level patches are children of the cover. The cover letter has its In-Reply-To and References stripped (it @@ -750,8 +822,9 @@ class LoreSeries: msg['References'] = f'<{cover_msgid}>' @staticmethod - def rethread_series(msgids: List[str], - all_msgs: List[EmailMessage]) -> Tuple[str, List[EmailMessage]]: + def rethread_series( + msgids: List[str], all_msgs: List[EmailMessage] + ) -> Tuple[str, List[EmailMessage]]: """Reconstitute a properly threaded series from unthreaded messages. Runs the full rethread pipeline: identify a cover letter (or use @@ -808,7 +881,9 @@ class LoreSeries: return 'undefined' prefix = lmsg.date.strftime('%Y%m%d') - authorline = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('from', [])])[0] + 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) @@ -832,16 +907,25 @@ class LoreSeries: if self.patches[0] and self.patches[0].followup_trailers: self.add_extra_trailers(self.patches[0].followup_trailers) - def get_am_ready(self, noaddtrailers: bool = False, addmysob: bool = False, - addlink: bool = False, cherrypick: Optional[List[int]] = None, copyccs: bool = False, - allowbadchars: bool = False, showchecks: bool = False) -> List[EmailMessage]: + def get_am_ready( + self, + noaddtrailers: bool = False, + addmysob: bool = False, + addlink: bool = False, + cherrypick: Optional[List[int]] = None, + copyccs: bool = False, + allowbadchars: bool = False, + showchecks: bool = False, + ) -> List[EmailMessage]: 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') + logger.critical( + 'WARNING: Unable to add your Signed-off-by: git returned no user.name or user.email' + ) addmysob = False attpolicy = str(config.get('attestation-policy', 'softfail')) @@ -864,7 +948,9 @@ class LoreSeries: attsame = False break - checkmark, trailers, attcrit = lmsg.get_attestation_trailers(attpolicy, maxdays) + checkmark, trailers, attcrit = lmsg.get_attestation_trailers( + attpolicy, maxdays + ) if attref is None: attref = trailers attmark = checkmark @@ -905,7 +991,10 @@ class LoreSeries: # TODO: Progress bar? lmsg.load_pw_ci_status() if not lmsg.pw_ci_status or lmsg.pw_ci_status == 'pending': - logger.debug('No CI on patch %s, skipping the rest of the checks', lmsg.counter) + logger.debug( + 'No CI on patch %s, skipping the rest of the checks', + lmsg.counter, + ) lmsg.pw_ci_status = None self.add_cover_trailers() @@ -917,10 +1006,16 @@ class LoreSeries: if cherrypick is not None: if at not in cherrypick: at += 1 - logger.debug(' skipped: [%s/%s] (not in cherrypick)', at, self.expected) + 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) + logger.critical( + 'CRITICAL: [%s/%s] is missing, cannot cherrypick', + at, + self.expected, + ) raise KeyError('Cherrypick not in series') if lmsg is not None: @@ -935,16 +1030,24 @@ class LoreSeries: llval = lparts[1].strip() % lmsg.msgid linktrailer = LoreTrailer(name=llname, value=llval) else: - logger.critical('linktrailermask does not look like a valid trailer, using defaults') + logger.critical( + 'linktrailermask does not look like a valid trailer, using defaults' + ) if not linktrailer: defmask = LOREADDR + '/r/%s' cfg_llval = config.get('linkmask', defmask) if isinstance(cfg_llval, str) and '%s' in cfg_llval: - linktrailer = LoreTrailer(name='Link', value=cfg_llval % lmsg.msgid) + linktrailer = LoreTrailer( + name='Link', value=cfg_llval % lmsg.msgid + ) else: - logger.critical('linkmask does not look like a valid mask, using defaults') - linktrailer = LoreTrailer(name='Link', value=defmask % lmsg.msgid) + logger.critical( + 'linkmask does not look like a valid mask, using defaults' + ) + linktrailer = LoreTrailer( + name='Link', value=defmask % lmsg.msgid + ) extras.append(linktrailer) @@ -955,7 +1058,9 @@ class LoreSeries: logger.info(' %s', lmsg.get_am_subject()) else: - checkmark, trailers, critical = lmsg.get_attestation_trailers(attpolicy, maxdays) + checkmark, trailers, critical = lmsg.get_attestation_trailers( + attpolicy, maxdays + ) if checkmark: logger.info(' %s %s', checkmark, lmsg.get_am_subject()) else: @@ -966,6 +1071,7 @@ class LoreSeries: if critical: import sys + logger.critical('---') logger.critical('Exiting due to attestation-policy: hardfail') sys.exit(128) @@ -973,11 +1079,20 @@ class LoreSeries: add_trailers = True if noaddtrailers: add_trailers = False - msg = lmsg.get_am_message(add_trailers=add_trailers, extras=extras, copyccs=copyccs, - addmysob=addmysob, allowbadchars=allowbadchars) + msg = lmsg.get_am_message( + add_trailers=add_trailers, + extras=extras, + copyccs=copyccs, + addmysob=addmysob, + allowbadchars=allowbadchars, + ) if local_check_cmds: lmsg.load_local_ci_status(local_check_cmds) - if lmsg.local_ci_status or lmsg.pw_ci_status in {'success', 'fail', 'warning'}: + if lmsg.local_ci_status or lmsg.pw_ci_status in { + 'success', + 'fail', + 'warning', + }: if lmsg.local_ci_status: for flag, status in lmsg.local_ci_status: logger.info(' %s %s', CI_FLAGS_FANCY[flag], status) @@ -985,8 +1100,12 @@ class LoreSeries: pwproj = config.get('pw-project') if lmsg.pw_ci_status in {'fail', 'warning'}: pwlink = f'{pwurl}/project/{pwproj}/patch/{lmsg.msgid}' - logger.info(' %s patchwork: %s: %s', CI_FLAGS_FANCY[lmsg.pw_ci_status], - str(lmsg.pw_ci_status).upper(), pwlink) + logger.info( + ' %s patchwork: %s: %s', + CI_FLAGS_FANCY[lmsg.pw_ci_status], + str(lmsg.pw_ci_status).upper(), + pwlink, + ) msgs.append(msg) else: logger.error(' ERROR: missing [%s/%s]!', at, self.expected) @@ -1002,7 +1121,6 @@ class LoreSeries: return msgs - @property def submission_date(self) -> Optional[datetime.datetime]: # Find the date of the first patch we have @@ -1015,7 +1133,6 @@ class LoreSeries: break return self._submission_date - @property def indexes(self) -> List[Tuple[str, str]]: if self._indexes is not None: @@ -1026,8 +1143,15 @@ class LoreSeries: if lmsg is None or not lmsg.blob_indexes: continue for ofn, obh, nfn, fmod in lmsg.blob_indexes: - logger.debug('%s/%s: ofn=%s, obh=%s, nfn=%s, fmod=%s', - lmsg.counter, lmsg.expected, ofn, obh, nfn, fmod) + logger.debug( + '%s/%s: ofn=%s, obh=%s, nfn=%s, fmod=%s', + lmsg.counter, + lmsg.expected, + ofn, + obh, + nfn, + fmod, + ) if ofn in seenfiles: # if we have seen this file once already, then it's a repeat patch # it's no longer going to match current hash @@ -1039,8 +1163,9 @@ class LoreSeries: self._indexes.append((ofn, obh)) return self._indexes - def check_applies_clean(self, gitdir: Optional[str] = None, - at: Optional[str] = None) -> Tuple[int, List[Tuple[str, str]]]: + def check_applies_clean( + self, gitdir: Optional[str] = None, at: Optional[str] = None + ) -> Tuple[int, List[Tuple[str, str]]]: mismatches = list() if at is None: at = 'HEAD' @@ -1059,7 +1184,12 @@ class LoreSeries: return len(self.indexes), mismatches - def find_base(self, gitdir: Optional[str], branches: Optional[List[str]] = None, maxdays: int = 30) -> Tuple[str, int, int]: + def find_base( + self, + gitdir: Optional[str], + branches: Optional[List[str]] = None, + maxdays: int = 30, + ) -> Tuple[str, int, int]: if self.indexes is None: self.populate_indexes() if self.indexes is None or not len(self.indexes): @@ -1076,7 +1206,13 @@ class LoreSeries: else: where = ['--all'] - gitargs = ['log', '--pretty=oneline', '--until', guntil, '--max-count=1'] + where + gitargs = [ + 'log', + '--pretty=oneline', + '--until', + guntil, + '--max-count=1', + ] + where lines = git_get_command_lines(gitdir, gitargs) if not lines: raise IndexError('No commits found before %s' % guntil) @@ -1090,8 +1226,16 @@ class LoreSeries: 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 + 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) @@ -1127,8 +1271,9 @@ class LoreSeries: raise IndexError('Could not describe commit %s' % best) - def make_fake_am_range(self, gitdir: Optional[str], - at_base: Optional[str] = None) -> Tuple[Optional[str], Optional[str]]: + def make_fake_am_range( + self, gitdir: Optional[str], at_base: Optional[str] = None + ) -> Tuple[Optional[str], Optional[str]]: start_commit = end_commit = None # Use the msgid of the first non-None patch in the series msgid = None @@ -1149,7 +1294,9 @@ class LoreSeries: stalecache = True if start_commit is not None and end_commit is not None: # Make sure they are still there - if git_commit_exists(gitdir, start_commit) and git_commit_exists(gitdir, end_commit): + if git_commit_exists(gitdir, start_commit) and git_commit_exists( + gitdir, end_commit + ): logger.debug('Using previously generated range') return start_commit, end_commit stalecache = True @@ -1175,7 +1322,10 @@ class LoreSeries: 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) + 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) @@ -1203,19 +1353,37 @@ class LoreSeries: try: ohash = git_revparse_obj(ofi) logger.debug(' Found matching blob for: %s', ofn) - gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{ohash},{ofn}'] + gitargs = [ + 'update-index', + '--add', + '--cacheinfo', + f'{fmod},{ohash},{ofn}', + ] except RuntimeError: - logger.debug('Could not find matching blob for %s (%s)', ofn, ofi) + logger.debug( + 'Could not find matching blob for %s (%s)', ofn, ofi + ) try: chash = git_revparse_obj(f':{ofn}', topdir) - gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{chash},{ofn}'] + gitargs = [ + 'update-index', + '--add', + '--cacheinfo', + f'{fmod},{chash},{ofn}', + ] except RuntimeError: - logger.critical(' ERROR: Could not find anything matching %s', ofn) + logger.critical( + ' ERROR: Could not find anything matching %s', ofn + ) return None, None ecode, out = git_run_command(None, gitargs) if ecode > 0: - logger.critical(' ERROR: Could not run update-index for %s (%s)', ofn, ohash) + logger.critical( + ' ERROR: Could not run update-index for %s (%s)', + ofn, + ohash, + ) return None, None msgs.append(lmsg.get_am_message(add_trailers=False)) @@ -1227,7 +1395,9 @@ class LoreSeries: treeid = out.strip() # At this point we have a worktree with files that should (hopefully) cleanly receive a git am gitargs = ['commit-tree', treeid + '^{tree}', '-F', '-'] - ecode, out = git_run_command(None, gitargs, stdin='Initial fake commit'.encode('utf-8')) + 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 @@ -1271,11 +1441,25 @@ class LoreTrailer: addr: Optional[Tuple[str, str]] = None lmsg: Optional['LoreMessage'] = None # Small list of recognized utility trailers - _utility: Set[str] = {'fixes', 'link', 'buglink', 'closes', 'obsoleted-by', 'message-id', 'change-id', - 'base-commit', 'based-on'} + _utility: Set[str] = { + 'fixes', + 'link', + 'buglink', + 'closes', + 'obsoleted-by', + 'message-id', + 'change-id', + 'base-commit', + 'based-on', + } - def __init__(self, name: Optional[str] = None, value: Optional[str] = None, extinfo: Optional[str] = None, - msg: Optional[EmailMessage] = None): + def __init__( + self, + name: Optional[str] = None, + value: Optional[str] = None, + extinfo: Optional[str] = None, + msg: Optional[EmailMessage] = None, + ): if name is None or value is None: self.name = 'Signed-off-by' self.type = 'person' @@ -1363,8 +1547,9 @@ class LoreTrailer: if olocal != tlocal: return False - return (abs(odomain.count('.') - tdomain.count('.')) == 1 - and (odomain.endswith(f'.{tdomain}') or tdomain.endswith(f'.{odomain}'))) + return abs(odomain.count('.') - tdomain.count('.')) == 1 and ( + odomain.endswith(f'.{tdomain}') or tdomain.endswith(f'.{odomain}') + ) @staticmethod def _extract_link_msgid(url: str) -> Optional[str]: @@ -1526,8 +1711,9 @@ class LoreMessage: self.references.append(self.in_reply_to) try: - fromdata = email.utils.getaddresses([LoreMessage.clean_header(str(x)) - for x in self.msg.get_all('from', [])])[0] + 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()): @@ -1572,19 +1758,33 @@ class LoreMessage: trailers, _others = LoreMessage.find_trailers(self.body, followup=True) # We only pay attention to trailers that are sent in reply if trailers and self.references and not self.has_diff and not self.reply: - logger.debug('A follow-up missing a Re: but containing a trailer with no patch diff') + logger.debug( + 'A follow-up missing a Re: but containing a trailer with no patch diff' + ) self.reply = True if self.reply: for trailer in trailers: # These are commonly part of patch/commit metadata - badtrailers = {'from', 'author', 'cc', 'to', 'date', 'subject', - 'subscribe', 'unsubscribe', 'base-commit', 'change-id', - 'message-id'} + badtrailers = { + 'from', + 'author', + 'cc', + 'to', + 'date', + 'subject', + 'subscribe', + 'unsubscribe', + 'base-commit', + 'change-id', + 'message-id', + } if trailer.lname not in badtrailers: trailer.lmsg = self self.trailers.append(trailer) - def get_trailers(self, sloppy: bool = False) -> Tuple[List[LoreTrailer], Set[LoreTrailer]]: + def get_trailers( + self, sloppy: bool = False + ) -> Tuple[List[LoreTrailer], Set[LoreTrailer]]: trailers = list() mismatches = set() @@ -1679,10 +1879,10 @@ class LoreMessage: # Identify 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): # type: ignore[attr-defined] + for header in list(self.msg._headers): # type: ignore[attr-defined] if header[0].lower() == 'dkim-signature': dkhdrs.append(header) - self.msg._headers.remove(header) # type: ignore[attr-defined] + self.msg._headers.remove(header) # type: ignore[attr-defined] dkhdrs.reverse() seenatts = list() @@ -1693,7 +1893,11 @@ class LoreMessage: 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.get('d'), hdata.get('s')) + logger.debug( + 'Loading DKIM attestation for d=%s, s=%s', + hdata.get('d'), + hdata.get('s'), + ) identity = hdata.get('i') if not identity: @@ -1712,9 +1916,11 @@ class LoreMessage: if isinstance(sh, str) and 'date' in sh.lower().split(':'): signtime = self.date - self.msg._headers.append((hn, hval)) # type: ignore[attr-defined] + self.msg._headers.append((hn, hval)) # type: ignore[attr-defined] try: - res = dkim.verify(self.msg.as_bytes(policy=emlpolicy), logger=dkimlogger) + res = dkim.verify( + self.msg.as_bytes(policy=emlpolicy), logger=dkimlogger + ) logger.debug('DKIM verify results: %s=%s', identity, res) except Exception as ex: # Usually, this is due to some DNS resolver failure, which we can't @@ -1729,7 +1935,7 @@ class LoreMessage: self._attestors.append(attestor) return - self.msg._headers.pop(-1) # type: ignore[attr-defined] + self.msg._headers.pop(-1) # type: ignore[attr-defined] seenatts.append(attestor) # No exact domain matches, so return everything we have @@ -1756,7 +1962,10 @@ class LoreMessage: if i.get('Subject') != self.subject: ibh.append('Subject: %s' % str(i.get('Subject'))) if i.get('Email') != self.fromemail or i.get('Author') != self.fromname: - ibh.append('From: ' + format_addrs([(str(i.get('Author')), str(i.get('Email')))])) + ibh.append( + 'From: ' + + format_addrs([(str(i.get('Author')), str(i.get('Email')))]) + ) if len(ibh): self.body = '\n'.join(ibh) + '\n\n' + self.body @@ -1771,10 +1980,16 @@ class LoreMessage: sources = config.get('keyringsrc') if not sources: # fallback to patatt's keyring if none is specified for b4 - patatt_config = patatt.get_config_from_git(r'patatt\..*', multivals=['keyringsrc']) + patatt_config = patatt.get_config_from_git( + r'patatt\..*', multivals=['keyringsrc'] + ) sources = patatt_config.get('keyringsrc') if not sources: - sources = ['ref:::.keys', 'ref:::.local-keys', 'ref::refs/meta/keyring:'] + sources = [ + 'ref:::.keys', + 'ref:::.local-keys', + 'ref::refs/meta/keyring:', + ] if not isinstance(sources, list): sources = [sources] if pdir not in sources: @@ -1782,8 +1997,9 @@ class LoreMessage: # Push our logger and GPGBIN into patatt patatt.logger = logger - assert isinstance(config['gpgbin'], str), \ + assert isinstance(config['gpgbin'], str), ( 'gpgbin config value is not a string: %s' % str(config['gpgbin']) + ) patatt.GPGBIN = config['gpgbin'] logger.debug('Loading patatt attestations with sources=%s', str(sources)) @@ -1791,7 +2007,9 @@ class LoreMessage: success = False trim_body = False while True: - attestations = patatt.validate_message(self.msg.as_bytes(policy=emlpolicy), sources, trim_body=trim_body) + attestations = patatt.validate_message( + self.msg.as_bytes(policy=emlpolicy), sources, trim_body=trim_body + ) # Do we have any successes? for attestation in attestations: if attestation[0] == patatt.RES_VALID: @@ -1826,18 +2044,22 @@ class LoreMessage: signdt = LoreAttestor.parse_ts(signtime) else: signdt = None - attestor = LoreAttestorPatatt(result, identity, signdt, keysrc, keyalgo, errors) + attestor = LoreAttestorPatatt( + result, identity, signdt, keysrc, keyalgo, errors + ) self._attestors.append(attestor) @staticmethod - def run_local_check(cmdargs: List[str], ident: str, msg: EmailMessage, - nocache: bool = False) -> List[Tuple[str, str]]: + def run_local_check( + cmdargs: List[str], ident: str, msg: EmailMessage, nocache: bool = False + ) -> List[Tuple[str, str]]: cacheid = ' '.join(cmdargs) + ident if not nocache: cachedata = get_cache(cacheid, suffix='checks', as_json=True) if cachedata is not None: - assert isinstance(cachedata, list), \ + assert isinstance(cachedata, list), ( 'Cache data for %s is not a list: %s' % (cacheid, str(cachedata)) + ) return cachedata logger.debug('Checking ident=%s using %s', ident, cmdargs[0]) @@ -1888,13 +2110,16 @@ class LoreMessage: pwurl = str(config.get('pw-url', '')) pwproj = str(config.get('pw-project', '')) if not (pwkey and pwurl and pwproj): - logger.debug('Patchwork support requires pw-key, pw-url and pw-project settings') + logger.debug( + 'Patchwork support requires pw-key, pw-url and pw-project settings' + ) raise LookupError('Error looking up %s in patchwork' % msgid) cachedata = get_cache(pwurl + pwproj + msgid, suffix='lookup', as_json=True) if cachedata is not None: - assert isinstance(cachedata, dict), \ + assert isinstance(cachedata, dict), ( 'Cache data for %s is not a dict: %s' % (msgid, str(cachedata)) + ) return cachedata pses, url = get_patchwork_session(pwkey, pwurl) @@ -1926,7 +2151,7 @@ class LoreMessage: save_cache(pwdata, pwurl + pwproj + msgid, suffix='lookup', is_json=True) return pwdata - def get_patchwork_info(self) -> Optional[Dict[str,str]]: + def get_patchwork_info(self) -> Optional[Dict[str, str]]: if not self.pwhash: return None try: @@ -1945,7 +2170,9 @@ class LoreMessage: logger.debug('ci_state for %s: %s', self.msgid, ci_status) self.pw_ci_status = ci_status - def get_attestation_status(self, attpolicy: str, maxdays: int = 0) -> Tuple[List[Dict[str, Any]], bool, bool]: + def get_attestation_status( + self, attpolicy: str, maxdays: int = 0 + ) -> Tuple[List[Dict[str, Any]], bool, bool]: """Get attestation status for this message. Args: @@ -1968,7 +2195,11 @@ class LoreMessage: critical = False for attestor in self.attestors: - if attestor.passing and maxdays and not attestor.check_time_drift(self.date, maxdays): + 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 @@ -1978,27 +2209,33 @@ class LoreMessage: if attestor.have_key: # This was signed, and we have a key, but it's failing has_failing = True - attestations.append({ - 'status': 'badsig', - 'identity': attestor.trailer, - 'passing': False, - }) + attestations.append( + { + 'status': 'badsig', + 'identity': attestor.trailer, + 'passing': False, + } + ) elif attpolicy in ('softfail', 'hardfail'): has_failing = True - attestations.append({ - 'status': 'nokey', - 'identity': attestor.trailer, - 'passing': False, - }) + attestations.append( + { + 'status': 'nokey', + 'identity': attestor.trailer, + 'passing': False, + } + ) # This is not critical even in hardfail continue elif attpolicy in ('softfail', 'hardfail'): has_failing = True - attestations.append({ - 'status': 'badsig', - 'identity': attestor.trailer, - 'passing': False, - }) + attestations.append( + { + 'status': 'badsig', + 'identity': attestor.trailer, + 'passing': False, + } + ) if attpolicy == 'hardfail': critical = True @@ -2018,9 +2255,12 @@ class LoreMessage: self.fromname = xpair[0] self.fromemail = xpair[1] # Drop the reply-to header if it's exactly the same - for header in list(self.msg._headers): # type: ignore[attr-defined] - if header[0].lower() == 'reply-to' and header[1].find(xpair[1]) > 0: - self.msg._headers.remove(header) # type: ignore[attr-defined] + for header in list(self.msg._headers): # type: ignore[attr-defined] + if ( + header[0].lower() == 'reply-to' + and header[1].find(xpair[1]) > 0 + ): + self.msg._headers.remove(header) # type: ignore[attr-defined] has_passing = True att_info: Dict[str, Any] = { @@ -2037,7 +2277,9 @@ class LoreMessage: overall_passing = not has_failing or has_passing return attestations, overall_passing, critical - def get_attestation_trailers(self, attpolicy: str, maxdays: int = 0) -> Tuple[Optional[str], List[str], bool]: + def get_attestation_trailers( + self, attpolicy: str, maxdays: int = 0 + ) -> Tuple[Optional[str], List[str], bool]: """Get formatted attestation trailers with checkmarks for display. Args: @@ -2050,7 +2292,9 @@ class LoreMessage: - trailers: List of formatted trailer strings with checkmarks - critical: True if hardfail policy triggered """ - attestations, _overall_passing, critical = self.get_attestation_status(attpolicy, maxdays) + attestations, _overall_passing, critical = self.get_attestation_status( + attpolicy, maxdays + ) config = get_main_config() if config['attestation-checkmarks'] == 'fancy': @@ -2069,7 +2313,9 @@ class LoreMessage: if checkmark is None: checkmark = mark if 'mismatch' in att: - trailers.append(f'{mark} Signed: {att["identity"]} (From: {att["mismatch"]})') + trailers.append( + f'{mark} Signed: {att["identity"]} (From: {att["mismatch"]})' + ) else: trailers.append(f'{mark} Signed: {att["identity"]}') else: @@ -2200,9 +2446,10 @@ class LoreMessage: return new_hdrval.strip() @staticmethod - def make_reply_addrs(to_addrs: List[Tuple[str, str]], - cc_addrs: List[Tuple[str, str]], - ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]: + def make_reply_addrs( + to_addrs: List[Tuple[str, str]], + cc_addrs: List[Tuple[str, str]], + ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]]]: """Deduplicate To and Cc address lists for a reply. Removes duplicates within To, then removes any Cc entries that @@ -2224,8 +2471,9 @@ class LoreMessage: deduped_cc.append((name, addr)) return deduped_to, deduped_cc - def make_reply(self, body: str, - mailfrom: Optional[Tuple[str, str]] = None) -> EmailMessage: + def make_reply( + self, body: str, mailfrom: Optional[Tuple[str, str]] = None + ) -> EmailMessage: """Build a reply EmailMessage addressing this message. Handles Reply-To → To promotion, folds the original To into Cc, @@ -2242,7 +2490,9 @@ class LoreMessage: subject = f'Re: {subject}' try: - reply_to = email.utils.getaddresses([str(x) for x in self.msg.get_all('reply-to', [])]) + reply_to = email.utils.getaddresses( + [str(x) for x in self.msg.get_all('reply-to', [])] + ) except Exception: reply_to = [] if reply_to: @@ -2251,15 +2501,21 @@ class LoreMessage: to_addrs = [(self.fromname, self.fromemail)] try: - orig_to = email.utils.getaddresses([str(x) for x in self.msg.get_all('to', [])]) + orig_to = email.utils.getaddresses( + [str(x) for x in self.msg.get_all('to', [])] + ) except Exception: orig_to = [] try: - orig_cc = email.utils.getaddresses([str(x) for x in self.msg.get_all('cc', [])]) + orig_cc = email.utils.getaddresses( + [str(x) for x in self.msg.get_all('cc', [])] + ) except Exception: orig_cc = [] - deduped_to, deduped_cc = LoreMessage.make_reply_addrs(to_addrs, orig_to + orig_cc) + deduped_to, deduped_cc = LoreMessage.make_reply_addrs( + to_addrs, orig_to + orig_cc + ) msg = EmailMessage() msg.set_payload(body, charset='utf-8') @@ -2276,15 +2532,24 @@ class LoreMessage: return msg @staticmethod - def wrap_header(hdr: Tuple[str, str], width: int = 75, nl: str = '\n', - transform: Literal['encode', 'decode', 'preserve'] = 'preserve') -> bytes: + def wrap_header( + hdr: Tuple[str, str], + width: int = 75, + nl: str = '\n', + transform: Literal['encode', 'decode', 'preserve'] = 'preserve', + ) -> bytes: hname, hval = hdr if hname.lower() in ('to', 'cc', 'from', 'x-original-from'): _parts = [f'{hname}: '] first = True for addr in email.utils.getaddresses([hval]): if transform == 'encode' and not addr[0].isascii(): - addr = (email.quoprimime.header_encode(addr[0].encode(), charset='utf-8'), addr[1]) + addr = ( + email.quoprimime.header_encode( + addr[0].encode(), charset='utf-8' + ), + addr[1], + ) qp = format_addrs([addr], clean=False) elif transform == 'decode': qp = format_addrs([addr], clean=True) @@ -2311,11 +2576,18 @@ class LoreMessage: # Use simple textwrap, with a small trick that ensures that long non-breakable # strings don't show up on the next line from the bare header hdata = hdata.replace(': ', ':_', 1) - wrapped = textwrap.wrap(hdata, break_long_words=False, break_on_hyphens=False, - subsequent_indent=' ', width=width) + wrapped = textwrap.wrap( + hdata, + break_long_words=False, + break_on_hyphens=False, + subsequent_indent=' ', + width=width, + ) return nl.join(wrapped).replace(':_', ': ', 1).encode() - qp = f'{hname}: ' + email.quoprimime.header_encode(hval.encode(), charset='utf-8') + qp = f'{hname}: ' + email.quoprimime.header_encode( + hval.encode(), charset='utf-8' + ) # is it longer than width? if len(qp) <= width: return qp.encode() @@ -2327,19 +2599,25 @@ class LoreMessage: # Also allow for the ' ' at the front on continuation lines wrapat -= 1 # Make sure we don't break on a =XX escape sequence - while '=' in qp[wrapat - 2:wrapat]: + while '=' in qp[wrapat - 2 : wrapat]: wrapat -= 1 _parts.append(qp[:wrapat] + '?=') - qp = ('=?utf-8?q?' + qp[wrapat:]) + qp = '=?utf-8?q?' + qp[wrapat:] _parts.append(qp) return f'{nl} '.join(_parts).encode() @staticmethod - def get_msg_as_bytes(msg: EmailMessage, nl: str = '\n', - headers: Literal['encode', 'decode', 'preserve'] = 'preserve') -> bytes: + def get_msg_as_bytes( + msg: EmailMessage, + nl: str = '\n', + headers: Literal['encode', 'decode', 'preserve'] = 'preserve', + ) -> bytes: bdata = b'' for hname, hval in msg.items(): - bdata += LoreMessage.wrap_header((hname, str(hval)), nl=nl, transform=headers) + nl.encode() + bdata += ( + LoreMessage.wrap_header((hname, str(hval)), nl=nl, transform=headers) + + nl.encode() + ) bdata += nl.encode() payload = msg.get_payload(decode=True) if not isinstance(payload, bytes): @@ -2371,7 +2649,9 @@ class LoreMessage: listidpref = config['listid-preference'] if not isinstance(listidpref, list): listidpref = [str(listidpref)] - return liblore.utils.get_preferred_duplicate(msg1, msg2, listid_preference=listidpref) + return liblore.utils.get_preferred_duplicate( + msg1, msg2, listid_preference=listidpref + ) @staticmethod def get_patch_id(diff: str) -> Optional[str]: @@ -2455,17 +2735,28 @@ class LoreMessage: return indexes @staticmethod - def find_trailers(body: str, followup: bool = False) -> Tuple[List[LoreTrailer], List[str]]: + def find_trailers( + body: str, followup: bool = False + ) -> Tuple[List[LoreTrailer], List[str]]: ignores = {'phone', 'mail', 'email', 'e-mail', 'prerequisite-message-id'} headers = {'subject', 'date', 'from'} links = {'link', 'buglink', 'closes'} - nonperson = links | {'fixes', 'subject', 'date', 'obsoleted-by', 'change-id', 'base-commit'} + nonperson = links | { + 'fixes', + 'subject', + 'date', + 'obsoleted-by', + 'change-id', + 'base-commit', + } # Ignore everything below standard email signature marker body = body.split('\n-- \n', 1)[0].strip() + '\n' # Fix some more common copypasta trailer wrapping # Fixes: abcd0123 (foo bar # baz quux) - body = re.sub(r'^(\S+:\s+[\da-f]+\s+\([^)]+)\n([^\n]+\))', r'\1 \2', body, flags=re.M) + body = re.sub( + r'^(\S+:\s+[\da-f]+\s+\([^)]+)\n([^\n]+\))', r'\1 \2', body, flags=re.M + ) # Signed-off-by: Long Name # <[email protected]> body = re.sub(r'^(\S+:\s+[^<]+)\n(<[^>]+>)$', r'\1 \2', body, flags=re.M) @@ -2490,19 +2781,31 @@ class LoreMessage: logger.debug('Ignoring %d: %s (known non-trailer)', at, line) continue if len(others) and lname in headers: - logger.debug('Ignoring %d: %s (header after other content)', at, line) + logger.debug( + 'Ignoring %d: %s (header after other content)', at, line + ) continue if followup: if not lname.isascii(): - logger.debug('Ignoring %d: %s (known non-ascii follow-up trailer)', at, lname) + logger.debug( + 'Ignoring %d: %s (known non-ascii follow-up trailer)', + at, + lname, + ) continue mperson = re.search(r'\S+@\S+\.\S+', ovalue) if not mperson and lname not in nonperson: - logger.debug('Ignoring %d: %s (not a recognized non-person trailer)', at, line) + logger.debug( + 'Ignoring %d: %s (not a recognized non-person trailer)', + at, + line, + ) continue mlink = re.search(r'https?://', ovalue) if mlink and lname not in links: - logger.debug('Ignoring %d: %s (not a recognized link trailer)', at, line) + logger.debug( + 'Ignoring %d: %s (not a recognized link trailer)', at, line + ) continue extinfo = None @@ -2531,8 +2834,13 @@ class LoreMessage: return trailers, others @staticmethod - def rebuild_message(headers: List[LoreTrailer], message: str, trailers: List[LoreTrailer], - basement: str, signature: str) -> str: + def rebuild_message( + headers: List[LoreTrailer], + message: str, + trailers: List[LoreTrailer], + basement: str, + signature: str, + ) -> str: body = '' if headers: for ltr in headers: @@ -2551,8 +2859,10 @@ class LoreMessage: if len(basement): if not len(trailers): body += '\n' - if (DIFFSTAT_RE.search(basement) - or not (basement.strip().startswith('diff --git') or basement.lstrip().startswith('--- '))): + if DIFFSTAT_RE.search(basement) or not ( + basement.strip().startswith('diff --git') + or basement.lstrip().startswith('--- ') + ): body += '---\n' else: # We don't need to add a --- @@ -2566,7 +2876,9 @@ class LoreMessage: return body @staticmethod - def get_body_parts(body: str) -> Tuple[List[LoreTrailer], str, List[LoreTrailer], str, str]: + def get_body_parts( + body: str, + ) -> Tuple[List[LoreTrailer], str, List[LoreTrailer], str, str]: # remove any starting/trailing blank lines body = body.replace('\r', '') body = body.strip('\n') @@ -2640,14 +2952,20 @@ class LoreMessage: return githeaders, message, trailers, basement, signature - def fix_trailers(self, extras: Optional[List[LoreTrailer]] = None, - copyccs: bool = False, addmysob: bool = False, - fallback_order: str = '*', - omit_trailers: Optional[List[str]] = None) -> None: + def fix_trailers( + self, + extras: Optional[List[LoreTrailer]] = None, + copyccs: bool = False, + addmysob: bool = False, + fallback_order: str = '*', + omit_trailers: Optional[List[str]] = None, + ) -> None: config = get_main_config() - bheaders, message, btrailers, basement, signature = LoreMessage.get_body_parts(self.body) + bheaders, message, btrailers, basement, signature = LoreMessage.get_body_parts( + self.body + ) sobtr = LoreTrailer() hasmysob = False @@ -2666,10 +2984,20 @@ class LoreMessage: addmysob = True if copyccs: - alldests = email.utils.getaddresses([str(x) for x in self.msg.get_all('to', [])]) - alldests += email.utils.getaddresses([str(x) for x in self.msg.get_all('cc', [])]) + 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]) + alldests.sort( + key=lambda x: ( + x[1].find('@') > 0 + and x[1].split('@')[1] + x[1].split('@')[0] + or x[1] + ) + ) for pair in alldests: found = False for fltr in btrailers + new_trailers: @@ -2687,7 +3015,9 @@ class LoreMessage: torder = config.get('trailer-order', fallback_order) if not isinstance(torder, str): - logger.critical('b4.trailer-order must be a string, falling back to default') + logger.critical( + 'b4.trailer-order must be a string, falling back to default' + ) torder = fallback_order if torder and torder != '*': @@ -2727,7 +3057,9 @@ class LoreMessage: if ltr in fixtrailers or ltr in ignored: continue - if (ltr.addr and ltr.addr[1].lower() in ignores) or (ltr.lmsg and ltr.lmsg.fromemail.lower() in ignores): + if (ltr.addr and ltr.addr[1].lower() in ignores) or ( + ltr.lmsg and ltr.lmsg.fromemail.lower() in ignores + ): logger.info(' x %s', ltr.as_string(omit_extinfo=True)) ignored.add(ltr) continue @@ -2742,8 +3074,11 @@ class LoreMessage: extra = ' (%s %s)' % (attestor.checkmark, attestor.trailer) if attpolicy == 'hardfail': import sys + logger.critical('---') - logger.critical('Exiting due to attestation-policy: hardfail') + logger.critical( + 'Exiting due to attestation-policy: hardfail' + ) sys.exit(1) logger.info(' + %s%s', ltr.as_string(omit_extinfo=True), extra) @@ -2784,9 +3119,13 @@ class LoreMessage: if bparts: self.message += '---\n' + '---\n'.join(bparts) - self.body = LoreMessage.rebuild_message(bheaders, message, fixtrailers, basement, signature) + self.body = LoreMessage.rebuild_message( + bheaders, message, fixtrailers, basement, signature + ) - def get_am_subject(self, indicate_reroll: bool = True, use_subject: Optional[str] = None) -> str: + def get_am_subject( + self, indicate_reroll: bool = True, use_subject: Optional[str] = None + ) -> str: # Return a clean patch subject parts = ['PATCH'] if self.lsubject.rfc: @@ -2794,9 +3133,14 @@ class LoreMessage: 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)) + 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)) + 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: @@ -2809,14 +3153,25 @@ class LoreMessage: return '[%s] %s' % (' '.join(parts), use_subject) - def get_am_message(self, add_trailers: bool = True, addmysob: bool = False, - extras: Optional[List['LoreTrailer']] = None, copyccs: bool = False, - allowbadchars: bool = False) -> EmailMessage: + def get_am_message( + self, + add_trailers: bool = True, + addmysob: bool = False, + extras: Optional[List['LoreTrailer']] = None, + copyccs: bool = False, + allowbadchars: bool = False, + ) -> EmailMessage: # Look through the body to make sure there aren't any suspicious unicode control flow chars # First, encode into ascii and compare for a quick utf8 presence test - if not allowbadchars and self.body.encode('ascii', errors='replace') != self.body.encode(): + if ( + not allowbadchars + and self.body.encode('ascii', errors='replace') != self.body.encode() + ): import unicodedata - logger.debug('Body contains non-ascii characters. Running Unicode Cf char tests.') + + logger.debug( + 'Body contains non-ascii characters. Running Unicode Cf char tests.' + ) for line in self.body.split('\n'): # Does this line have any unicode? if line.encode() == line.encode('ascii', errors='replace'): @@ -2829,12 +3184,20 @@ class LoreMessage: for at, c in enumerate(line.rstrip('\r')): if unicodedata.category(c) == 'Cf': logger.critical('---') - logger.critical('WARNING: Message contains suspicious unicode control characters!') + logger.critical( + 'WARNING: Message contains suspicious unicode control characters!' + ) logger.critical(' Subject: %s', self.full_subject) logger.critical(' Line: %s', line.rstrip('\r')) logger.critical(' ------%s^', '-' * at) - logger.critical(' Char: %s (%s)', unicodedata.name(c), hex(ord(c))) - logger.critical(' If you are sure about this, rerun with the right flag to allow.') + logger.critical( + ' Char: %s (%s)', + unicodedata.name(c), + hex(ord(c)), + ) + logger.critical( + ' If you are sure about this, rerun with the right flag to allow.' + ) sys.exit(1) # Remove anything cut off by scissors @@ -2854,7 +3217,10 @@ class LoreMessage: am_msg = EmailMessage() hfrom = format_addrs([(str(i.get('Author', '')), str(i.get('Email')))]) - am_msg.add_header('Subject', self.get_am_subject(indicate_reroll=False, use_subject=i.get('Subject'))) + am_msg.add_header( + 'Subject', + self.get_am_subject(indicate_reroll=False, use_subject=i.get('Subject')), + ) am_msg.add_header('From', hfrom) am_msg.add_header('Date', str(i.get('Date'))) am_msg.add_header('Message-Id', f'<{self.msgid}>') @@ -2893,7 +3259,9 @@ class LoreSubject: 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): + if re.search(r'^(Re|Aw|Fwd):', subject, re.I) or re.search( + r'^\w{2,3}:\s*\[', subject + ): subject = re.sub(r'^\w{2,3}:\s*\[', '[', subject) self.reply = True @@ -2956,8 +3324,9 @@ class LoreSubject: return ret - def get_rebuilt_subject(self, eprefixes: Optional[List[str]] = None, - presubject: Optional[str] = None) -> str: + def get_rebuilt_subject( + self, eprefixes: Optional[List[str]] = None, presubject: Optional[str] = None + ) -> str: exclude = None if eprefixes and 'PATCH' in eprefixes: @@ -2972,9 +3341,12 @@ class LoreSubject: if self.revision > 1: _pfx.append(f'v{self.revision}') if self.expected > 1: - _pfx.append('%s/%s' % (str(self.counter).zfill(len(str(self.expected))), self.expected)) + _pfx.append( + '%s/%s' + % (str(self.counter).zfill(len(str(self.expected))), self.expected) + ) - subject = "" + subject = '' if len(_pfx): subject = '[' + ' '.join(_pfx) + '] ' + self.subject else: @@ -3077,15 +3449,27 @@ class LoreAttestor: if self.level == 'domain': if emlfrom.lower().endswith('@' + self.identity.lower()): - logger.debug('PASS : sig domain %s matches from identity %s', self.identity, emlfrom) + 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)) + self.errors.append( + 'signing domain %s does not match From: %s' % (self.identity, emlfrom) + ) return False if emlfrom.lower() == self.identity.lower(): - logger.debug('PASS : sig identity %s matches from identity %s', self.identity, emlfrom) + 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)) + self.errors.append( + 'signing identity %s does not match From: %s' % (self.identity, emlfrom) + ) return False @staticmethod @@ -3113,7 +3497,13 @@ class LoreAttestor: class LoreAttestorDKIM(LoreAttestor): - def __init__(self, passing: bool, identity: str, signtime: Optional[datetime.datetime], errors: List[str]) -> None: + def __init__( + self, + passing: bool, + identity: str, + signtime: Optional[datetime.datetime], + errors: List[str], + ) -> None: super().__init__() self.mode = 'DKIM' self.level = 'domain' @@ -3128,12 +3518,15 @@ class LoreAttestorDKIM(LoreAttestor): class LoreAttestorPatatt(LoreAttestor): - def __init__(self, result: int, - identity: Optional[str], - signtime: Optional[datetime.datetime], - keysrc: Optional[str], - keyalgo: Optional[str], - errors: List[str]) -> None: + def __init__( + self, + result: int, + identity: Optional[str], + signtime: Optional[datetime.datetime], + keysrc: Optional[str], + keyalgo: Optional[str], + errors: List[str], + ) -> None: super().__init__() self.mode = 'patatt' self.level = 'person' @@ -3149,8 +3542,9 @@ class LoreAttestorPatatt(LoreAttestor): self.have_key = True -def _run_command(cmdargs: List[str], stdin: Optional[bytes] = None, - rundir: Optional[str] = None) -> Tuple[int, bytes, bytes]: +def _run_command( + cmdargs: List[str], stdin: Optional[bytes] = None, rundir: Optional[str] = None +) -> Tuple[int, bytes, bytes]: if rundir: logger.debug('Changing dir to %s', rundir) curdir = os.getcwd() @@ -3159,7 +3553,9 @@ def _run_command(cmdargs: List[str], stdin: Optional[bytes] = None, curdir = None logger.debug('Running %s', ' '.join(cmdargs)) - sp = subprocess.Popen(cmdargs, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE) + sp = subprocess.Popen( + cmdargs, stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.PIPE + ) (output, error) = sp.communicate(input=stdin) if curdir: logger.debug('Changing back into %s', curdir) @@ -3168,7 +3564,9 @@ def _run_command(cmdargs: List[str], stdin: Optional[bytes] = None, return sp.returncode, output, error -def gpg_run_command(args: List[str], stdin: Optional[bytes] = None) -> Tuple[int, bytes, bytes]: +def gpg_run_command( + args: List[str], stdin: Optional[bytes] = None +) -> Tuple[int, bytes, bytes]: config = get_main_config() gpgbin = config.get('gpgbin', 'gpg') if not isinstance(gpgbin, str): @@ -3183,22 +3581,38 @@ def gpg_run_command(args: List[str], stdin: Optional[bytes] = None) -> Tuple[int @overload -def git_run_command(gitdir: Optional[Union[str, Path]], args: List[str], stdin: Optional[bytes] = ..., - *, logstderr: bool = ..., decode: Literal[False], - rundir: Optional[str] = ...) -> Tuple[int, bytes]: - ... +def git_run_command( + gitdir: Optional[Union[str, Path]], + args: List[str], + stdin: Optional[bytes] = ..., + *, + logstderr: bool = ..., + decode: Literal[False], + rundir: Optional[str] = ..., +) -> Tuple[int, bytes]: ... @overload -def git_run_command(gitdir: Optional[Union[str, Path]], args: List[str], stdin: Optional[bytes] = ..., - *, logstderr: bool = ..., decode: Literal[True] = ..., - rundir: Optional[str] = ...) -> Tuple[int, str]: - ... - - -def git_run_command(gitdir: Optional[Union[str, Path]], args: List[str], stdin: Optional[bytes] = None, - *, logstderr: bool = False, decode: bool = True, - rundir: Optional[str] = None) -> Tuple[int, Union[str, bytes]]: +def git_run_command( + gitdir: Optional[Union[str, Path]], + args: List[str], + stdin: Optional[bytes] = ..., + *, + logstderr: bool = ..., + decode: Literal[True] = ..., + rundir: Optional[str] = ..., +) -> Tuple[int, str]: ... + + +def git_run_command( + gitdir: Optional[Union[str, Path]], + args: List[str], + stdin: Optional[bytes] = None, + *, + logstderr: bool = False, + decode: bool = True, + rundir: Optional[str] = None, +) -> Tuple[int, Union[str, bytes]]: cmdargs = ['git', '--no-pager'] if gitdir: if os.path.exists(os.path.join(gitdir, '.git')): @@ -3228,13 +3642,16 @@ def git_run_command(gitdir: Optional[Union[str, Path]], args: List[str], stdin: def git_check_minimal_version(min_version: str) -> bool: - _ecode, out = git_run_command(None, ["version"]) - current_version = re.sub(r"git version (\d+\.\d+)\..*", r"\1", out) - return tuple(map(int, current_version.split(".")[:2])) >= tuple(map(int, min_version.split(".")[:2])) - + _ecode, out = git_run_command(None, ['version']) + current_version = re.sub(r'git version (\d+\.\d+)\..*', r'\1', out) + return tuple(map(int, current_version.split('.')[:2])) >= tuple( + map(int, min_version.split('.')[:2]) + ) -def git_credential_fill(gitdir: Optional[str], protocol: str, host: str, username: str) -> Optional[str]: +def git_credential_fill( + gitdir: Optional[str], protocol: str, host: str, username: str +) -> Optional[str]: stdin = f'protocol={protocol}\nhost={host}\nusername={username}\n'.encode() ecode, out = git_run_command(gitdir, args=['credential', 'fill'], stdin=stdin) if ecode == 0: @@ -3258,7 +3675,9 @@ def git_get_command_lines(gitdir: Optional[str], args: List[str]) -> List[str]: return lines -def git_get_repo_status(gitdir: Optional[str] = None, untracked: bool = False) -> List[str]: +def git_get_repo_status( + gitdir: Optional[str] = None, untracked: bool = False +) -> List[str]: args = ['status', '--porcelain=v1'] if not untracked: args.append('--untracked-files=no') @@ -3266,7 +3685,9 @@ def git_get_repo_status(gitdir: Optional[str] = None, untracked: bool = False) - @contextmanager -def git_temp_worktree(gitdir: Optional[str] = None, commitish: Optional[str] = None) -> Generator[str, None, None]: +def git_temp_worktree( + gitdir: Optional[str] = None, commitish: Optional[str] = None +) -> Generator[str, None, 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 @@ -3323,7 +3744,9 @@ def setup_config(cmdargs: argparse.Namespace) -> None: _setup_sendemail_config(cmdargs) -def _cmdline_config_override(cmdargs: argparse.Namespace, config: Dict[str, Any], section: str) -> None: +def _cmdline_config_override( + cmdargs: argparse.Namespace, config: Dict[str, Any], section: str +) -> None: """Use cmdline.config to set and override config values for section.""" if not cmdargs.config: return @@ -3331,7 +3754,7 @@ def _cmdline_config_override(cmdargs: argparse.Namespace, config: Dict[str, Any] section += '.' config_override = { - key[len(section):]: val + key[len(section) :]: val for key, val in cmdargs.config.items() if key.startswith(section) } @@ -3339,14 +3762,20 @@ def _cmdline_config_override(cmdargs: argparse.Namespace, config: Dict[str, Any] config.update(config_override) -def git_set_config(fullpath: Optional[str], param: str, value: str, operation: str = '--replace-all') -> int: +def git_set_config( + fullpath: Optional[str], param: str, value: str, operation: str = '--replace-all' +) -> int: args = ['config', operation, param, value] ecode, _out = git_run_command(fullpath, args) return ecode -def get_config_from_git(regexp: str, defaults: Optional[Dict[str, Any]] = None, - multivals: Optional[List[str]] = None, source: Optional[str] = None) -> Dict[str, Any]: +def get_config_from_git( + regexp: str, + defaults: Optional[Dict[str, Any]] = None, + multivals: Optional[List[str]] = None, + source: Optional[str] = None, +) -> Dict[str, Any]: if multivals is None: multivals = list() args = ['config'] @@ -3396,10 +3825,23 @@ def _setup_main_config(cmdargs: Optional[argparse.Namespace] = None) -> None: # some options can be provided via the toplevel .b4-config file, # so load them up and use as defaults topdir = git_get_toplevel() - wtglobs = ['prep-*-check-cmd', 'review-*-check-cmd', 'send-*', '*mask', '*template*', 'trailer*', 'pw-*'] - multivals = ['keyringsrc', 'am-perpatch-check-cmd', 'prep-perpatch-check-cmd', - 'review-perpatch-check-cmd', 'review-series-check-cmd', - 'review-target-branch'] + wtglobs = [ + 'prep-*-check-cmd', + 'review-*-check-cmd', + 'send-*', + '*mask', + '*template*', + 'trailer*', + 'pw-*', + ] + multivals = [ + 'keyringsrc', + 'am-perpatch-check-cmd', + 'prep-perpatch-check-cmd', + 'review-perpatch-check-cmd', + 'review-series-check-cmd', + 'review-target-branch', + ] if topdir: wtcfg = os.path.join(topdir, '.b4-config') if os.access(wtcfg, os.R_OK): @@ -3427,10 +3869,13 @@ def _setup_main_config(cmdargs: Optional[argparse.Namespace] = None) -> None: # If we specify DNS resolvers, configure them now if config['attestation-dns-resolvers'] is not None: try: - resolvers = [x.strip() for x in config['attestation-dns-resolvers'].split(',')] + resolvers = [ + x.strip() for x in config['attestation-dns-resolvers'].split(',') + ] if resolvers: # Don't force this as an automatically discovered dependency import dns.resolver + dns.resolver.default_resolver = dns.resolver.Resolver(configure=False) dns.resolver.default_resolver.nameservers = resolvers except ImportError: @@ -3472,7 +3917,10 @@ def get_cache_dir(appname: str = 'b4') -> str: try: expmin = int(str(config['cache-expire'])) * 60 except ValueError: - logger.critical('ERROR: cache-expire must be an integer (minutes): %s', config['cache-expire']) + logger.critical( + 'ERROR: cache-expire must be an integer (minutes): %s', + config['cache-expire'], + ) expmin = 600 expage = time.time() - expmin # Expire anything else that is older than 30 days @@ -3503,7 +3951,9 @@ def get_cache_file(identifier: str, suffix: Optional[str] = None) -> str: return os.path.join(cachedir, cachefile) -def get_cache(identifier: str, suffix: Optional[str] = None, as_json: bool = False) -> Optional[Any]: +def get_cache( + identifier: str, suffix: Optional[str] = None, as_json: bool = False +) -> Optional[Any]: fullpath = get_cache_file(identifier, suffix=suffix) cachedata = None try: @@ -3530,7 +3980,9 @@ def clear_cache(identifier: str, suffix: Optional[str] = None) -> None: logger.debug('Removed cache %s for %s', fullpath, identifier) -def save_cache(contents: Any, identifier: str, suffix: Optional[str] = None, is_json: bool = False) -> None: +def save_cache( + contents: Any, identifier: str, suffix: Optional[str] = None, is_json: bool = False +) -> None: fullpath = get_cache_file(identifier, suffix=suffix) try: with open(fullpath, 'w') as fh: @@ -3554,7 +4006,7 @@ def _setup_user_config(cmdargs: argparse.Namespace) -> None: USER_CONFIG['name'] = os.environ['GIT_AUTHOR_NAME'] else: udata = pwd.getpwuid(os.getuid()) - USER_CONFIG['name'] = udata.pw_gecos.strip(",") + USER_CONFIG['name'] = udata.pw_gecos.strip(',') if 'email' not in USER_CONFIG: if 'GIT_COMMITTER_EMAIL' in os.environ: USER_CONFIG['email'] = os.environ['GIT_COMMITTER_EMAIL'] @@ -3613,8 +4065,9 @@ def get_lore_node() -> liblore.LoreNode: def get_msgid_from_stdin() -> Optional[str]: if not sys.stdin.isatty(): - message = email.parser.BytesParser(policy=emlpolicy, _class=EmailMessage).parsebytes( - sys.stdin.buffer.read(), headersonly=True) + message = email.parser.BytesParser( + policy=emlpolicy, _class=EmailMessage + ).parsebytes(sys.stdin.buffer.read(), headersonly=True) msgid = message.get('Message-ID', None) if msgid: return str(msgid) @@ -3626,7 +4079,9 @@ def parse_msgid(msgid: str) -> str: msgid = msgid.strip().strip('<>') # Handle the case when someone pastes a full URL to the message # Is this a patchwork URL? - matches = re.search(r'^https?://.*/project/.*/patch/([^/]+@[^/]+)', msgid, re.IGNORECASE) + matches = re.search( + r'^https?://.*/project/.*/patch/([^/]+@[^/]+)', msgid, re.IGNORECASE + ) if matches: logger.debug('Looks like a patchwork URL') chunks = matches.groups() @@ -3671,7 +4126,9 @@ def get_msgid(cmdargs: argparse.Namespace) -> Optional[str]: return parse_msgid(msgid) -def get_strict_thread(msgs: List[EmailMessage], msgid: str, noparent: bool = False) -> Optional[List[EmailMessage]]: +def get_strict_thread( + msgs: List[EmailMessage], msgid: str, noparent: bool = False +) -> Optional[List[EmailMessage]]: # Attempt to automatically recognize the situation when someone posts # a standalone patch or series in the middle of a large discussion for another series. # We recommend dealing with this using --no-parent, but we can also catch this @@ -3711,26 +4168,36 @@ def mailsplit_bytes(bmbox: bytes, pipesep: Optional[str] = None) -> List[EmailMe logger.debug('Mailsplitting using pipesep=%s', pipesep) if '\\' in pipesep: import codecs + pipesep = codecs.decode(pipesep.encode(), 'unicode_escape') msgs: List[EmailMessage] = [] for chunk in bmbox.split(pipesep.encode()): if chunk.strip(): - msgs.append(email.parser.BytesParser(policy=emlpolicy, - _class=EmailMessage).parsebytes(chunk)) + msgs.append( + email.parser.BytesParser( + policy=emlpolicy, _class=EmailMessage + ).parsebytes(chunk) + ) return msgs return liblore.utils.split_mbox(bmbox) -def get_pi_search_results(query: str, nocache: bool = False, message: Optional[str] = None, - full_threads: bool = True) -> Optional[List[EmailMessage]]: +def get_pi_search_results( + query: str, + nocache: bool = False, + message: Optional[str] = None, + full_threads: bool = True, +) -> Optional[List[EmailMessage]]: node = get_lore_node() if message is not None and len(message): logger.info(message, node.hostname) else: logger.info('Grabbing search results from %s', node.hostname) try: - t_mbox = node.get_mbox_by_query(query, full_threads=full_threads, nocache=nocache) + t_mbox = node.get_mbox_by_query( + query, full_threads=full_threads, nocache=nocache + ) except liblore.RemoteError: logger.info('Server returned an error.') return None @@ -3761,7 +4228,9 @@ def get_series_by_msgid(msgid: str, nocache: bool = False) -> Optional['LoreMail return lmbx -def get_series_by_change_id(change_id: str, nocache: bool = False) -> Optional['LoreMailbox']: +def get_series_by_change_id( + change_id: str, nocache: bool = False +) -> Optional['LoreMailbox']: q = f'nq:"change-id:{change_id}"' q_msgs = get_pi_search_results(q, nocache=nocache, full_threads=False) if not q_msgs: @@ -3770,11 +4239,15 @@ def get_series_by_change_id(change_id: str, nocache: bool = False) -> Optional[' for q_msg in q_msgs: body, _bcharset = LoreMessage.get_payload(q_msg) if not re.search(rf'^\s*change-id:\s*{change_id}$', body, flags=re.M | re.I): - logger.debug('No change-id match for %s', q_msg.get('Subject', '(no subject)')) + logger.debug( + 'No change-id match for %s', q_msg.get('Subject', '(no subject)') + ) continue q_msgid = LoreMessage.get_clean_msgid(q_msg) if q_msgid is None: - logger.debug('No message-id found, ignoring %s', q_msg.get('Subject', '(no subject)')) + logger.debug( + 'No message-id found, ignoring %s', q_msg.get('Subject', '(no subject)') + ) continue t_msgs = get_pi_thread_by_msgid(q_msgid, nocache=nocache) if t_msgs: @@ -3784,9 +4257,12 @@ def get_series_by_change_id(change_id: str, nocache: bool = False) -> Optional[' return lmbx -def get_msgs_by_patch_id(patch_id: str, extra_query: Optional[str] = None, - nocache: bool = False, full_threads: bool = False - ) -> Optional[List[EmailMessage]]: +def get_msgs_by_patch_id( + patch_id: str, + extra_query: Optional[str] = None, + nocache: bool = False, + full_threads: bool = False, +) -> Optional[List[EmailMessage]]: q = f'patchid:{patch_id}' if extra_query: q = f'{q} {extra_query}' @@ -3798,7 +4274,9 @@ def get_msgs_by_patch_id(patch_id: str, extra_query: Optional[str] = None, return q_msgs -def get_series_by_patch_id(patch_id: str, nocache: bool = False) -> Optional['LoreMailbox']: +def get_series_by_patch_id( + patch_id: str, nocache: bool = False +) -> Optional['LoreMailbox']: q_msgs = get_msgs_by_patch_id(patch_id, full_threads=True, nocache=nocache) if not q_msgs: return None @@ -3809,10 +4287,13 @@ def get_series_by_patch_id(patch_id: str, nocache: bool = False) -> Optional['Lo return lmbx -def get_pi_thread_by_msgid(msgid: str, nocache: bool = False, - onlymsgids: Optional[Set[str]] = None, - with_thread: bool = True, - quiet: bool = False) -> Optional[List[EmailMessage]]: +def get_pi_thread_by_msgid( + msgid: str, + nocache: bool = False, + onlymsgids: Optional[Set[str]] = None, + with_thread: bool = True, + quiet: bool = False, +) -> Optional[List[EmailMessage]]: if not quiet: logger.info('Looking up %s', msgid) node = get_lore_node() @@ -3847,16 +4328,20 @@ def get_pi_thread_by_msgid(msgid: str, nocache: bool = False, return strict -def git_range_to_patches(gitdir: Optional[str], start: str, end: str, - prefixes: Optional[List[str]] = None, - revision: Optional[int] = 1, - msgid_tpt: Optional[str] = None, - seriests: Optional[int] = None, - mailfrom: Optional[Tuple[str, str]] = None, - extrahdrs: Optional[List[Tuple[str, str]]] = None, - ignore_commits: Optional[Set[str]] = None, - limit_committer: Optional[str] = None, - presubject: Optional[str] = None) -> List[Tuple[str, EmailMessage]]: +def git_range_to_patches( + gitdir: Optional[str], + start: str, + end: str, + prefixes: Optional[List[str]] = None, + revision: Optional[int] = 1, + msgid_tpt: Optional[str] = None, + seriests: Optional[int] = None, + mailfrom: Optional[Tuple[str, str]] = None, + extrahdrs: Optional[List[Tuple[str, str]]] = None, + ignore_commits: Optional[Set[str]] = None, + limit_committer: Optional[str] = None, + presubject: Optional[str] = None, +) -> List[Tuple[str, EmailMessage]]: gitargs = ['rev-list', '--no-merges', '--reverse'] if limit_committer: gitargs += ['-F', f'--committer={limit_committer}'] @@ -3881,20 +4366,23 @@ def git_range_to_patches(gitdir: Optional[str], start: str, end: str, '--find-renames', ] - if git_check_minimal_version("2.40"): - showargs.append("--default-prefix") + if git_check_minimal_version('2.40'): + showargs.append('--default-prefix') smcfg = get_sendemail_config() if not get_git_bool(str(smcfg.get('mailmap', 'false'))): showargs.append('--no-mailmap') logger.debug('showargs=%s', showargs) ecode, out = git_run_command( - gitdir, ['show'] + showargs + [commit], + gitdir, + ['show'] + showargs + [commit], decode=False, ) if ecode > 0: raise RuntimeError(f'Could not get a patch out of {commit}') - msg = email.parser.BytesParser(policy=emlpolicy, _class=EmailMessage).parsebytes(out) + msg = email.parser.BytesParser( + policy=emlpolicy, _class=EmailMessage + ).parsebytes(out) patches.append((commit, msg)) fullcount = len(patches) @@ -3917,8 +4405,9 @@ def git_range_to_patches(gitdir: Optional[str], start: str, end: str, lsubject.expected = expected if revision is not None: lsubject.revision = revision - subject = lsubject.get_rebuilt_subject(eprefixes=prefixes, - presubject=presubject) + subject = lsubject.get_rebuilt_subject( + eprefixes=prefixes, presubject=presubject + ) logger.debug(' %s', subject) msg.replace_header('Subject', subject) @@ -3937,7 +4426,9 @@ def git_range_to_patches(gitdir: Optional[str], start: str, end: str, patchts = seriests + counter + 1 origdate = msg.get('Date') if origdate: - msg.replace_header('Date', email.utils.formatdate(patchts, localtime=True)) + msg.replace_header( + 'Date', email.utils.formatdate(patchts, localtime=True) + ) else: msg.add_header('Date', email.utils.formatdate(patchts, localtime=True)) @@ -3988,7 +4479,9 @@ def git_revparse_tag(gitdir: Optional[str], tagname: str) -> Optional[str]: return out.strip() -def git_branch_contains(gitdir: Optional[str], commit_id: str, checkall: bool = False) -> List[str]: +def git_branch_contains( + gitdir: Optional[str], commit_id: str, checkall: bool = False +) -> List[str]: gitargs = ['branch', '--format=%(refname:short)', '--contains', commit_id] if checkall: gitargs.append('--all') @@ -4031,8 +4524,9 @@ def git_get_common_dir(path: Optional[str] = None) -> Optional[str]: return None -def format_addrs(pairs: List[Tuple[str, str]], clean: bool = True, - header_safe: bool = True) -> str: +def format_addrs( + pairs: List[Tuple[str, str]], clean: bool = True, header_safe: bool = True +) -> str: addrs = list() for pair in pairs: if not pair[0] or pair[0] == pair[1]: @@ -4069,7 +4563,9 @@ def print_pretty_addrs(addrs: List[Tuple[str, str]], hdrname: str) -> None: def make_quote(body: str, maxlines: int = 5) -> str: - _headers, message, _trailers, _basement, _signature = LoreMessage.get_body_parts(body) + _headers, message, _trailers, _basement, _signature = LoreMessage.get_body_parts( + body + ) if not len(message): # Sometimes there is no message, just trailers return '> \n' @@ -4122,7 +4618,9 @@ def parse_int_range(intrange: str, upper: int) -> Iterator[int]: logger.critical('Unknown range value specified: %s', n) -def check_gpg_status(status: str) -> Tuple[bool, bool, bool, Optional[str], Optional[str]]: +def check_gpg_status( + status: str, +) -> Tuple[bool, bool, bool, Optional[str], Optional[str]]: good = False valid = False trusted = False @@ -4139,7 +4637,9 @@ def check_gpg_status(status: str) -> Tuple[bool, bool, bool, Optional[str], Opti if gs_matches: good = True keyid = gs_matches.groups()[0] - vs_matches = re.search(r'^\[GNUPG:] VALIDSIG ([\dA-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', status, flags=re.M) + vs_matches = re.search( + r'^\[GNUPG:] VALIDSIG ([\dA-F]+) (\d{4}-\d{2}-\d{2}) (\d+)', status, flags=re.M + ) if vs_matches: valid = True signtime = vs_matches.groups()[2] @@ -4181,8 +4681,12 @@ def save_git_am_mbox(msgs: List[EmailMessage], dest: BinaryIO) -> None: dest.write(LoreMessage.get_msg_as_bytes(msg, headers='decode')) -def save_mboxrd_mbox(msgs: List[EmailMessage], dest: BinaryIO, mangle_from: bool = False) -> None: - gen = email.generator.BytesGenerator(dest, mangle_from_=mangle_from, policy=emlpolicy) +def save_mboxrd_mbox( + msgs: List[EmailMessage], dest: BinaryIO, mangle_from: bool = False +) -> None: + gen = email.generator.BytesGenerator( + dest, mangle_from_=mangle_from, policy=emlpolicy + ) for msg in msgs: dest.write(b'From mboxrd@z Thu Jan 1 00:00:00 1970\n') gen.flatten(msg) @@ -4198,13 +4702,20 @@ def save_maildir(msgs: List[EmailMessage], dest: str) -> None: 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()) + slug = '%04d_%s' % ( + lsubj.counter, + re.sub(r'\W+', '_', lsubj.subject).strip('_').lower(), + ) with open(os.path.join(d_tmp, f'{slug}.eml'), 'wb') as mfh: mfh.write(LoreMessage.get_msg_as_bytes(msg, headers='decode')) - os.rename(os.path.join(d_tmp, f'{slug}.eml'), os.path.join(d_new, f'{slug}.eml')) + 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[str, str], bytes, bytes]: +def get_mailinfo( + bmsg: bytes, scissors: bool = False +) -> Tuple[Dict[str, str], bytes, bytes]: with tempfile.TemporaryDirectory() as tfd: m_out = os.path.join(tfd, 'm') p_out = os.path.join(tfd, 'p') @@ -4258,10 +4769,16 @@ def _setup_sendemail_config(cmdargs: argparse.Namespace) -> None: identity = config.get('sendemail-identity') or _basecfg.get('identity') if identity: # Use this identity to override what we got from the default one - sconfig = get_config_from_git(rf'sendemail\.{identity}\..*', multivals=['smtpserveroption'], defaults=_basecfg) + sconfig = get_config_from_git( + rf'sendemail\.{identity}\..*', + multivals=['smtpserveroption'], + defaults=_basecfg, + ) sectname = f'sendemail.{identity}' if not len(sconfig): - raise smtplib.SMTPException('Unable to find %s settings in any applicable git config' % sectname) + raise smtplib.SMTPException( + 'Unable to find %s settings in any applicable git config' % sectname + ) else: sconfig = _basecfg sectname = 'sendemail' @@ -4277,7 +4794,9 @@ def get_sendemail_config() -> Dict[str, Optional[Union[str, List[str]]]]: return SENDEMAIL_CONFIG -def get_smtp(dryrun: bool = False) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], str]: +def get_smtp( + dryrun: bool = False, +) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], str]: sconfig = get_sendemail_config() # Limited support for smtp settings to begin with, but should cover the vast majority of cases fromaddr = sconfig.get('from') @@ -4292,7 +4811,9 @@ def get_smtp(dryrun: bool = False) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL try: port = int(str(sconfig.get('smtpserverport', '0'))) except ValueError as exc: - raise smtplib.SMTPException('Invalid smtpport entry in config: %s' % sconfig.get('smtpserverport')) from exc + raise smtplib.SMTPException( + 'Invalid smtpport entry in config: %s' % sconfig.get('smtpserverport') + ) from exc # If server contains slashes, then it's a local command if '/' in server: @@ -4337,7 +4858,9 @@ def get_smtp(dryrun: bool = False) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL # We do TLS from the get-go smtp = smtplib.SMTP_SSL(server, port) else: - raise smtplib.SMTPException('Unclear what to do with smtpencryption=%s' % encryption) + raise smtplib.SMTPException( + 'Unclear what to do with smtpencryption=%s' % encryption + ) # If we got to this point, we should do authentication, # unless smtpauth is set to a special "none" value @@ -4353,11 +4876,15 @@ def get_smtp(dryrun: bool = False) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL gchost = f'{server}:{port}' else: gchost = server - gc_pass = git_credential_fill(None, protocol='smtp', host=gchost, username=auser) + gc_pass = git_credential_fill( + None, protocol='smtp', host=gchost, username=auser + ) if gc_pass: apass = gc_pass if not apass: - raise smtplib.SMTPException('No password specified for connecting to %s', server) + raise smtplib.SMTPException( + 'No password specified for connecting to %s', server + ) if auser and apass: # Let any exceptions bubble up if smtpauth in ('oauth', 'oauth2', 'xoauth2'): @@ -4374,10 +4901,12 @@ def get_smtp(dryrun: bool = False) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL def get_patchwork_session(pwkey: str, pwurl: str) -> Tuple[requests.Session, str]: session = requests.session() - session.headers.update({ - 'User-Agent': 'b4/%s' % __VERSION__, - 'Authorization': f'Token {pwkey}', - }) + session.headers.update( + { + 'User-Agent': 'b4/%s' % __VERSION__, + 'Authorization': f'Token {pwkey}', + } + ) url = '/'.join((pwurl.rstrip('/'), 'api', PW_REST_API_VERSION)) logger.debug('pw url=%s', url) return session, url @@ -4390,7 +4919,9 @@ def patchwork_set_state(msgids: List[str], state: str) -> None: pwurl = str(config.get('pw-url', '')) pwproj = str(config.get('pw-project', '')) if not (pwkey and pwurl and pwproj): - logger.debug('Patchwork support requires pw-key, pw-url and pw-project settings') + logger.debug( + 'Patchwork support requires pw-key, pw-url and pw-project settings' + ) return pses, url = get_patchwork_session(pwkey, pwurl) patches_url = '/'.join((url, 'patches')) @@ -4432,11 +4963,17 @@ def patchwork_set_state(msgids: List[str], state: str) -> None: logger.debug('Patchwork REST error: %s', ex) -def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs: Sequence[EmailMessage], - fromaddr: Optional[str], destaddrs: Optional[Union[Set[str], List[str]]] = None, - patatt_sign: bool = False, dryrun: bool = False, - output_dir: Optional[str] = None, web_endpoint: Optional[str] = None, - reflect: bool = False) -> Optional[int]: +def send_mail( + smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], + msgs: Sequence[EmailMessage], + fromaddr: Optional[str], + destaddrs: Optional[Union[Set[str], List[str]]] = None, + patatt_sign: bool = False, + dryrun: bool = False, + output_dir: Optional[str] = None, + web_endpoint: Optional[str] = None, + reflect: bool = False, +) -> Optional[int]: tosend = list() if output_dir is not None: dryrun = True @@ -4457,16 +4994,21 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs ls = LoreSubject(subject) if patatt_sign: import patatt + # patatt.logger = logger try: bdata = patatt.rfc2822_sign(bdata) except patatt.NoKeyError as ex: logger.critical('CRITICAL: Error signing: no key configured') - logger.critical(' Run "patatt genkey" or configure "user.signingKey" to use PGP') + logger.critical( + ' Run "patatt genkey" or configure "user.signingKey" to use PGP' + ) logger.critical(' As a last resort, rerun with --no-sign') raise RuntimeError(str(ex)) from ex except patatt.SigningError as ex: - raise RuntimeError('Failure trying to patatt-sign: %s' % str(ex)) from ex + raise RuntimeError( + 'Failure trying to patatt-sign: %s' % str(ex) + ) from ex if dryrun: if output_dir: filen = '%s.eml' % ls.get_slug(sep='-') @@ -4481,7 +5023,9 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs continue if not destaddrs: alldests = email.utils.getaddresses([str(x) for x in msg.get_all('to', [])]) - alldests += email.utils.getaddresses([str(x) for x in msg.get_all('cc', [])]) + alldests += email.utils.getaddresses( + [str(x) for x in msg.get_all('cc', [])] + ) myaddrs = {x[1] for x in alldests} else: myaddrs = set(destaddrs) @@ -4538,7 +5082,9 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs cmdargs = list(smtp) + list(destaddrs) ecode, _out, err = _run_command(cmdargs, stdin=bdata) if ecode > 0: - raise RuntimeError('Error running %s: %s' % (' '.join(smtp), err.decode())) + raise RuntimeError( + 'Error running %s: %s' % (' '.join(smtp), err.decode()) + ) sent += 1 elif smtp: @@ -4555,7 +5101,9 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs return sent -def git_get_current_branch(gitdir: Optional[str] = None, short: bool = True) -> Optional[str]: +def git_get_current_branch( + gitdir: Optional[str] = None, short: bool = True +) -> Optional[str]: gitargs = ['symbolic-ref', '-q', 'HEAD'] ecode, out = git_run_command(gitdir, gitargs) if ecode > 0: @@ -4578,13 +5126,14 @@ def get_excluded_addrs() -> Set[str]: return excludes -def cleanup_email_addrs(addresses: List[Tuple[str, str]], excludes: Set[str], - gitdir: Optional[str]) -> List[Tuple[str, str]]: +def cleanup_email_addrs( + addresses: List[Tuple[str, str]], excludes: Set[str], gitdir: Optional[str] +) -> List[Tuple[str, str]]: global ALIAS_INFO global MAILMAP_INFO # Translate aliases if support is available - if git_check_minimal_version("2.47"): + if git_check_minimal_version('2.47'): logger.debug('Translating aliases via git send-email') unqual_addrs: Set[str] = set() @@ -4598,23 +5147,27 @@ def cleanup_email_addrs(addresses: List[Tuple[str, str]], excludes: Set[str], tocheck = list(unqual_addrs) data = '\n'.join(tocheck).encode('utf-8') args = ['send-email', '--translate-aliases'] - ecode, out = git_run_command(gitdir, - ['send-email', '--translate-aliases'], - stdin=data) + ecode, out = git_run_command( + gitdir, ['send-email', '--translate-aliases'], stdin=data + ) if ecode == 0: translated_addrs = email.utils.getaddresses(out.strip().splitlines()) for alias, entry in zip(tocheck, translated_addrs): if alias != entry[1]: - logger.debug('Translated alias %s to qualified address %s', - alias, entry[1]) + logger.debug( + 'Translated alias %s to qualified address %s', + alias, + entry[1], + ) ALIAS_INFO[alias] = entry else: logger.debug('"%s" is not a known alias', alias) ALIAS_INFO[alias] = None else: - logger.debug('git send-email --translate-aliases failed with exit code %s', - ecode) + logger.debug( + 'git send-email --translate-aliases failed with exit code %s', ecode + ) def _replace_aliases(entry: Tuple[str, str]) -> Tuple[str, str]: if entry[1] in ALIAS_INFO: @@ -4646,7 +5199,9 @@ def cleanup_email_addrs(addresses: List[Tuple[str, str]], excludes: Set[str], replacement = MAILMAP_INFO[entry[1]] # If it's None, we don't want to replace it if replacement is not None: - logger.debug('Replaced %s with mailmap-updated %s', entry[1], replacement[1]) + logger.debug( + 'Replaced %s with mailmap-updated %s', entry[1], replacement[1] + ) addresses.remove(entry) addresses.append(replacement) continue @@ -4707,8 +5262,13 @@ def discover_rethread_series(msgid: str, nocache: bool = False) -> List[str]: seed_msg = seed_msgs[0] seed = LoreMessage(seed_msg) - logger.info('Seed: [%d/%d] %s (from %s)', - seed.counter, seed.expected, seed.subject, seed.fromemail) + logger.info( + 'Seed: [%d/%d] %s (from %s)', + seed.counter, + seed.expected, + seed.subject, + seed.fromemail, + ) # Build a 1-hour date window around the seed (30 min each way) # Convert to UTC since public-inbox dt: expects UTC timestamps @@ -4757,7 +5317,9 @@ def discover_rethread_series(msgid: str, nocache: bool = False) -> List[str]: if seed.counters_inferred: if not found_bare: - logger.warning('Could not find any matching patches, using seed message only') + logger.warning( + 'Could not find any matching patches, using seed message only' + ) return [msgid] logger.info('Discovered %d bare patches from the same author', len(found_bare)) return found_bare @@ -4774,15 +5336,18 @@ def discover_rethread_series(msgid: str, nocache: bool = False) -> List[str]: n_patches = sum(1 for c in found if c > 0) if n_patches < expected: missing = [str(i) for i in range(1, expected + 1) if i not in found] - logger.warning('Found %d/%d patches (missing: %s)', - n_patches, expected, ', '.join(missing)) + logger.warning( + 'Found %d/%d patches (missing: %s)', n_patches, expected, ', '.join(missing) + ) else: logger.info('Discovered %d/%d patches for the series', n_patches, expected) return msgids -def fetch_rethread_messages(msgids: List[str], nocache: bool = False) -> Tuple[List[str], List[EmailMessage]]: +def fetch_rethread_messages( + msgids: List[str], nocache: bool = False +) -> Tuple[List[str], List[EmailMessage]]: """Fetch messages for multiple msgids, deduplicating across threads. Returns (msgids, all_msgs) where msgids is the input list (for @@ -4810,7 +5375,9 @@ def fetch_rethread_messages(msgids: List[str], nocache: bool = False) -> Tuple[L return msgids, all_msgs -def retrieve_rethreaded_messages(cmdargs: argparse.Namespace) -> Tuple[str, List[EmailMessage]]: +def retrieve_rethreaded_messages( + cmdargs: argparse.Namespace, +) -> Tuple[str, List[EmailMessage]]: """Retrieve messages from multiple unthreaded msgids and rethread them into a series.""" raw_ids: List[str] = cmdargs.rethread @@ -4848,7 +5415,9 @@ def retrieve_rethreaded_messages(cmdargs: argparse.Namespace) -> Tuple[str, List return LoreSeries.rethread_series(msgids, all_msgs) -def retrieve_messages(cmdargs: argparse.Namespace) -> Tuple[Optional[str], Optional[List[EmailMessage]]]: +def retrieve_messages( + cmdargs: argparse.Namespace, +) -> Tuple[Optional[str], Optional[List[EmailMessage]]]: # Handle --rethread mode: fetch multiple unrelated messages and stitch them together if getattr(cmdargs, 'rethread', None): if not can_network: @@ -4875,14 +5444,24 @@ def retrieve_messages(cmdargs: argparse.Namespace) -> Tuple[Optional[str], Optio if ('cherrypick' in cmdargs and cmdargs.cherrypick == '_') or not with_thread: # Just that msgid, please pickings.add(msgid) - msgs = get_pi_thread_by_msgid(msgid, nocache=cmdargs.nocache, onlymsgids=pickings, with_thread=with_thread) or [] + msgs = ( + get_pi_thread_by_msgid( + msgid, + nocache=cmdargs.nocache, + onlymsgids=pickings, + with_thread=with_thread, + ) + or [] + ) if not msgs: logger.debug('No messages from the query') return None, msgs else: if cmdargs.localmbox == '-': # The entire mbox is passed via stdin, so mailsplit it and use the first message for our msgid - msgs = mailsplit_bytes(sys.stdin.buffer.read(), pipesep=cmdargs.stdin_pipe_sep) + msgs = mailsplit_bytes( + sys.stdin.buffer.read(), pipesep=cmdargs.stdin_pipe_sep + ) if not len(msgs): raise LookupError('Stdin did not contain any messages') @@ -4895,7 +5474,9 @@ def retrieve_messages(cmdargs: argparse.Namespace) -> Tuple[Optional[str], Optio if with_thread: msgs = get_strict_thread(mb_msgs, msgid) or [] if not msgs: - raise LookupError('Could not find %s in %s' % (msgid, cmdargs.localmbox)) + raise LookupError( + 'Could not find %s in %s' % (msgid, cmdargs.localmbox) + ) else: msgs = list() for msg in mb_msgs: @@ -4929,8 +5510,9 @@ def git_revparse_obj(gitobj: str, gitdir: Optional[str] = None) -> str: def _rewrite_fetch_head_origin(topdir: str, old_origin: str, new_origin: str) -> None: """Rewrite FETCH_HEAD to replace old_origin with a descriptive message.""" - ecode, fhf = git_run_command(topdir, ['rev-parse', '--git-path', 'FETCH_HEAD'], - logstderr=True) + ecode, fhf = git_run_command( + topdir, ['rev-parse', '--git-path', 'FETCH_HEAD'], logstderr=True + ) if ecode > 0: return fhf = fhf.rstrip() @@ -4943,9 +5525,14 @@ def _rewrite_fetch_head_origin(topdir: str, old_origin: str, new_origin: str) -> fhh.write(new_contents) -def git_fetch_am_into_repo(gitdir: Optional[str], ambytes: bytes, at_base: str = 'HEAD', - origin: Optional[str] = None, check_only: bool = False, - am_flags: Optional[List[str]] = None) -> None: +def git_fetch_am_into_repo( + gitdir: Optional[str], + ambytes: bytes, + at_base: str = 'HEAD', + origin: Optional[str] = None, + check_only: bool = False, + am_flags: Optional[List[str]] = None, +) -> None: if gitdir is None: gitdir = os.getcwd() topdir = git_get_toplevel(gitdir) @@ -4969,12 +5556,16 @@ def git_fetch_am_into_repo(gitdir: Optional[str], ambytes: bytes, at_base: str = cleanup = True try: logger.info('Magic: Preparing a sparse worktree') - ecode, out = git_run_command(gwt, ['sparse-checkout', 'set'], logstderr=True, rundir=gwt) + ecode, out = git_run_command( + gwt, ['sparse-checkout', 'set'], logstderr=True, rundir=gwt + ) if ecode > 0: logger.critical('Error running sparse-checkout set') logger.critical(out) raise RuntimeError - ecode, out = git_run_command(gwt, ['checkout', '-f'], logstderr=True, rundir=gwt) + ecode, out = git_run_command( + gwt, ['checkout', '-f'], logstderr=True, rundir=gwt + ) if ecode > 0: logger.critical('Error running checkout into sparse workdir') logger.critical(out) @@ -4982,7 +5573,9 @@ def git_fetch_am_into_repo(gitdir: Optional[str], ambytes: bytes, at_base: str = amargs = ['am'] if am_flags: amargs.extend(am_flags) - ecode, out = git_run_command(gwt, amargs, stdin=ambytes, logstderr=True, rundir=gwt) + ecode, out = git_run_command( + gwt, amargs, stdin=ambytes, logstderr=True, rundir=gwt + ) if ecode > 0: cleanup = False raise AmConflictError(gwt, out.strip()) @@ -5044,13 +5637,21 @@ def edit_in_editor(bdata: bytes, filehint: str = 'COMMIT_EDITMSG') -> bytes: write_branch = git_get_current_branch() if write_branch != read_branch: - with tempfile.NamedTemporaryFile(mode="wb", prefix=f"old-{read_branch}".replace("/", "-"), - delete=False) as save_file: + with tempfile.NamedTemporaryFile( + mode='wb', prefix=f'old-{read_branch}'.replace('/', '-'), delete=False + ) as save_file: save_file.write(bdata) - logger.critical('Editing started on branch %s, but current branch is %s.', - read_branch, write_branch) - logger.critical('To avoid a collision, your text was saved in %s', save_file.name) - raise RuntimeError(f"Branch changed during file editing, the temporary file was saved at {save_file.name}") + logger.critical( + 'Editing started on branch %s, but current branch is %s.', + read_branch, + write_branch, + ) + logger.critical( + 'To avoid a collision, your text was saved in %s', save_file.name + ) + raise RuntimeError( + f'Branch changed during file editing, the temporary file was saved at {save_file.name}' + ) return bdata @@ -5095,8 +5696,9 @@ def view_in_pager(bdata: bytes, filehint: str = 'b4-view.txt') -> None: spop.wait() -def map_codereview_trailers(qmsgs: List[EmailMessage], - ignore_msgids: Optional[Set[str]] = None) -> Dict[str, List['LoreMessage']]: +def map_codereview_trailers( + qmsgs: List[EmailMessage], ignore_msgids: Optional[Set[str]] = None +) -> Dict[str, List['LoreMessage']]: """ Map messages containing code-review trailers to patch-ids they were sent for. :param qmsgs: list of messages to process @@ -5150,9 +5752,14 @@ def map_codereview_trailers(qmsgs: List[EmailMessage], # Is it a patch? logger.debug(' subj: %s', _qmsg.full_subject) # Is it the cover letter? - if (_qmsg.counter == 0 and (not _qmsg.counters_inferred or _qmsg.has_diffstat) - and _qmsg.msgid in ref_map): - logger.debug(' stopping: found the cover letter for %s', qlmsg.full_subject) + if ( + _qmsg.counter == 0 + and (not _qmsg.counters_inferred or _qmsg.has_diffstat) + and _qmsg.msgid in ref_map + ): + logger.debug( + ' stopping: found the cover letter for %s', qlmsg.full_subject + ) if _qmsg.msgid not in covers: covers[_qmsg.msgid] = set() covers[_qmsg.msgid].add(qlmsg.msgid) @@ -5166,7 +5773,9 @@ def map_codereview_trailers(qmsgs: List[EmailMessage], patchid_map[pqpid] = list() if qlmsg not in patchid_map[pqpid]: patchid_map[pqpid].append(qlmsg) - logger.debug(' matched patch-id %s to %s', pqpid, qlmsg.full_subject) + logger.debug( + ' matched patch-id %s to %s', pqpid, qlmsg.full_subject + ) pfound = True break else: @@ -5187,7 +5796,9 @@ def map_codereview_trailers(qmsgs: List[EmailMessage], if qlmsg.in_reply_to == cmsgid and qlmsg.git_patch_id: pqpid = qlmsg.git_patch_id for fwmsgid in fwmsgids: - logger.debug('Adding cover follow-up %s to patch-id %s', fwmsgid, pqpid) + logger.debug( + 'Adding cover follow-up %s to patch-id %s', fwmsgid, pqpid + ) if pqpid not in patchid_map: patchid_map[pqpid] = list() patchid_map[pqpid].append(qmid_map[fwmsgid]) @@ -5215,7 +5826,7 @@ def get_msgs_from_mailbox_or_maildir(mbmd: str) -> List[EmailMessage]: return [x[1] for x in in_mdr.items()] # type: ignore[misc] in_mbx = mailbox.mbox(mbmd, factory=mailbox_email_factory) # type: ignore[arg-type] - return[x[1] for x in in_mbx.items()] # type: ignore[misc] + return [x[1] for x in in_mbx.items()] # type: ignore[misc] def get_mailfrom() -> Tuple[str, str]: @@ -5234,6 +5845,8 @@ def make_msgid(idstring: Optional[str] = None, domain: str = 'b4') -> str: def is_maildir(dest: str) -> bool: - return (os.path.isdir(os.path.join(dest, 'new')) - and os.path.isdir(os.path.join(dest, 'cur')) - and os.path.isdir(os.path.join(dest, 'tmp'))) + return ( + os.path.isdir(os.path.join(dest, 'new')) + and os.path.isdir(os.path.join(dest, 'cur')) + and os.path.isdir(os.path.join(dest, 'tmp')) + ) diff --git a/src/b4/bugs/__init__.py b/src/b4/bugs/__init__.py index cb21611..1cd2db4 100644 --- a/src/b4/bugs/__init__.py +++ b/src/b4/bugs/__init__.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2020 by the Linux Foundation """b4 bugs: manage bug reports from mailing list threads.""" + import argparse import json import logging @@ -46,14 +47,21 @@ def _ensure_identity(topdir: str) -> bool: git_email = git_email.strip() if ecode_e == 0 else '' for user in users: if user.get('email', '') == git_email: - ecode, _out, _err = git_bug_cli(topdir, ['user', 'adopt', user['id']]) + ecode, _out, _err = git_bug_cli( + topdir, ['user', 'adopt', user['id']] + ) if ecode == 0: - logger.info('Adopted existing git-bug identity: %s', user.get('name', '')) + logger.info( + 'Adopted existing git-bug identity: %s', + user.get('name', ''), + ) return True # No email match -- adopt the first one ecode, _out, _err = git_bug_cli(topdir, ['user', 'adopt', users[0]['id']]) if ecode == 0: - logger.info('Adopted existing git-bug identity: %s', users[0].get('name', '')) + logger.info( + 'Adopted existing git-bug identity: %s', users[0].get('name', '') + ) return True # No identities at all -- create from git config after confirmation @@ -62,7 +70,9 @@ def _ensure_identity(topdir: str) -> bool: git_name = git_name.strip() if ecode_n == 0 else '' git_email = git_email.strip() if ecode_e == 0 else '' if not git_name or not git_email: - logger.critical('Cannot create git-bug identity: git user.name/user.email not configured') + logger.critical( + 'Cannot create git-bug identity: git user.name/user.email not configured' + ) return False logger.info('No git-bug identity found for this repository.') @@ -74,9 +84,18 @@ def _ensure_identity(topdir: str) -> bool: if answer and answer != 'y': return False - ecode, out, err = git_bug_cli(topdir, [ - 'user', 'new', '-n', git_name, '-e', git_email, '--non-interactive', - ]) + ecode, out, err = git_bug_cli( + topdir, + [ + 'user', + 'new', + '-n', + git_name, + '-e', + git_email, + '--non-interactive', + ], + ) if ecode != 0: logger.critical('Failed to create git-bug identity: %s', err.strip()) return False @@ -115,8 +134,9 @@ def cmd_import(cmdargs: argparse.Namespace) -> None: except RuntimeError as exc: logger.critical('Import failed: %s', exc) sys.exit(1) - logger.info('Created bug %s: %s (%d comments)', - bug.id[:7], bug.title, len(bug.comments)) + logger.info( + 'Created bug %s: %s (%d comments)', bug.id[:7], bug.title, len(bug.comments) + ) def cmd_refresh(cmdargs: argparse.Namespace) -> None: @@ -140,8 +160,7 @@ def cmd_refresh(cmdargs: argparse.Namespace) -> None: if count: logger.info('Bug %s: %d new comment(s)', bug.id[:7], count) total += count - logger.info('Refreshed %d bug(s), %d new comment(s) total', - len(bugs), total) + logger.info('Refreshed %d bug(s), %d new comment(s) total', len(bugs), total) def cmd_list(cmdargs: argparse.Namespace) -> None: @@ -161,8 +180,7 @@ def cmd_list(cmdargs: argparse.Namespace) -> None: for bug in bugs: icon = '\u25cf' if bug.status == Status.OPEN else '\u25cb' labels = ' '.join(f'[{label}]' for label in sorted(bug.labels)) - logger.info('%s %s %s %s', - icon, bug.id[:7], bug.title, labels) + logger.info('%s %s %s %s', icon, bug.id[:7], bug.title, labels) def cmd_delete(cmdargs: argparse.Namespace) -> None: diff --git a/src/b4/bugs/_import.py b/src/b4/bugs/_import.py index 954ebec..9114f70 100644 --- a/src/b4/bugs/_import.py +++ b/src/b4/bugs/_import.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2020 by the Linux Foundation """Thread import engine: lore.kernel.org -> git-bug via ezgb.""" + import email.utils import logging import re @@ -101,6 +102,7 @@ def _get_clean_msgid(msg: EmailMessage) -> Optional[str]: def _sort_by_date(msgs: list[EmailMessage]) -> list[EmailMessage]: """Sort messages by their Date header, oldest first.""" + def _date_key(msg: EmailMessage) -> float: raw = msg.get('Date') if raw: @@ -109,11 +111,14 @@ def _sort_by_date(msgs: list[EmailMessage]) -> list[EmailMessage]: ) return parsed.timestamp() return 0.0 + return sorted(msgs, key=_date_key) def import_thread( - repo: GitBugRepo, msgid: str, noparent: bool = False, + repo: GitBugRepo, + msgid: str, + noparent: bool = False, ) -> Bug: """Import a lore.kernel.org thread as a new git-bug bug. @@ -132,9 +137,7 @@ def import_thread( if noparent: filtered = b4.get_strict_thread(msgs, msgid, noparent=True) if not filtered: - raise RuntimeError( - f'No messages in sub-thread for {msgid}' - ) + raise RuntimeError(f'No messages in sub-thread for {msgid}') msgs = filtered msgs = b4.mbox.minimize_thread(msgs) @@ -181,7 +184,8 @@ def import_thread( subject = '(no subject)' scope = 'no-parent' if noparent else '' bug = repo.create_bug( - title=subject, body=format_comment(root, scope=scope), + title=subject, + body=format_comment(root, scope=scope), ) # Add follow-up messages as comments, sorted by date diff --git a/src/b4/bugs/_tui.py b/src/b4/bugs/_tui.py index 998e6bb..aaf079f 100644 --- a/src/b4/bugs/_tui.py +++ b/src/b4/bugs/_tui.py @@ -3,6 +3,7 @@ # SPDX-License-Identifier: GPL-2.0-or-later # Copyright (C) 2020 by the Linux Foundation """Textual TUI for b4 bugs.""" + import email.message import email.utils import hashlib @@ -59,25 +60,25 @@ logger = logging.getLogger('b4') # Material UI colors used by git-bug for deterministic label coloring. # See entities/common/label.go in git-bug. _LABEL_COLORS = [ - (244, 67, 54), # red - (233, 30, 99), # pink - (156, 39, 176), # purple - (103, 58, 183), # deepPurple - (63, 81, 181), # indigo - (33, 150, 243), # blue - (3, 169, 244), # lightBlue - (0, 188, 212), # cyan - (0, 150, 136), # teal - (76, 175, 80), # green - (139, 195, 74), # lightGreen - (205, 220, 57), # lime - (255, 235, 59), # yellow - (255, 193, 7), # amber - (255, 152, 0), # orange - (255, 87, 34), # deepOrange - (121, 85, 72), # brown + (244, 67, 54), # red + (233, 30, 99), # pink + (156, 39, 176), # purple + (103, 58, 183), # deepPurple + (63, 81, 181), # indigo + (33, 150, 243), # blue + (3, 169, 244), # lightBlue + (0, 188, 212), # cyan + (0, 150, 136), # teal + (76, 175, 80), # green + (139, 195, 74), # lightGreen + (205, 220, 57), # lime + (255, 235, 59), # yellow + (255, 193, 7), # amber + (255, 152, 0), # orange + (255, 87, 34), # deepOrange + (121, 85, 72), # brown (158, 158, 158), # grey - (96, 125, 139), # blueGrey + (96, 125, 139), # blueGrey ] @@ -94,13 +95,13 @@ def label_color(label: str) -> str: _LIFECYCLE_SYMBOLS: dict[str, str] = { - 'new': '\u2605', # ★ black star - 'confirmed': '\u00a4', # ¤ currency sign (bug-like) + 'new': '\u2605', # ★ black star + 'confirmed': '\u00a4', # ¤ currency sign (bug-like) 'worksforme': '\u00f8', # ø latin small letter o with stroke - 'needinfo': '\u203d', # ‽ interrobang - 'wontfix': '\u2260', # ≠ not equal to - 'fixed': '\u2713', # ✓ check mark - 'duplicate': '\u2261', # ≡ identical to + 'needinfo': '\u203d', # ‽ interrobang + 'wontfix': '\u2260', # ≠ not equal to + 'fixed': '\u2713', # ✓ check mark + 'duplicate': '\u2261', # ≡ identical to } @@ -109,13 +110,13 @@ _LIFECYCLE_SYMBOLS: dict[str, str] = { # 1 = waiting (pending external input) # 2 = resolved (no action needed) _LIFECYCLE_TIER: dict[str, int] = { - 'new': 0, - 'confirmed': 0, - 'needinfo': 1, + 'new': 0, + 'confirmed': 0, + 'needinfo': 1, 'worksforme': 2, - 'wontfix': 2, - 'fixed': 2, - 'duplicate': 2, + 'wontfix': 2, + 'fixed': 2, + 'duplicate': 2, } @@ -125,7 +126,7 @@ def _bug_tier(bug: BugLike) -> int: return 2 for lb in bug.labels: if lb.startswith('lifecycle:'): - state = lb[len('lifecycle:'):] + state = lb[len('lifecycle:') :] return _LIFECYCLE_TIER.get(state, 0) return 0 @@ -148,7 +149,7 @@ def _bug_lifecycle(bug: BugLike) -> str: """ for lb in bug.labels: if lb.startswith('lifecycle:'): - state = lb[len('lifecycle:'):] + state = lb[len('lifecycle:') :] return _LIFECYCLE_SYMBOLS.get(state, '?') if bug.status == Status.CLOSED: return '\u00d7' # × multiplication sign @@ -193,8 +194,7 @@ def _relative_time(dt: datetime) -> str: return f'{years}y ago' -def _render_comment(viewer: RichLog, text: str, - ts: dict[str, str]) -> None: +def _render_comment(viewer: RichLog, text: str, ts: dict[str, str]) -> None: """Render an RFC 2822 formatted comment into a RichLog.""" if '\n\n' in text: header_block, body = text.split('\n\n', 1) @@ -206,8 +206,8 @@ def _render_comment(viewer: RichLog, text: str, colon = line.find(':') if colon > 0: hdr_text = Text() - hdr_text.append(line[:colon + 1], style='bold') - hdr_text.append(line[colon + 1:]) + hdr_text.append(line[: colon + 1], style='bold') + hdr_text.append(line[colon + 1 :]) viewer.write(hdr_text) else: viewer.write(Text(line)) @@ -225,6 +225,7 @@ def _render_comment(viewer: RichLog, text: str, # -- List item widget -------------------------------------------------------- + def _bug_submitter(bug: Bug) -> str: """Get the submitter name from the first comment's From header.""" if bug.comments: @@ -253,8 +254,7 @@ class BugListItem(ListItem): count = bug.comment_count else: submitter = _bug_submitter(bug) - count = sum(1 for c in bug.comments - if not is_comment_removed(c.text)) + count = sum(1 for c in bug.comments if not is_comment_removed(c.text)) if display_width(submitter) > 20: while display_width(submitter) > 19: submitter = submitter[:-1] @@ -276,10 +276,11 @@ class BugListItem(ListItem): # -- Modal screens ----------------------------------------------------------- + class ImportScreen(ModalScreen[Optional[str]]): """Modal for importing a lore thread by message-id.""" - DEFAULT_CSS = ''' + DEFAULT_CSS = """ ImportScreen { align: center middle; } @@ -295,7 +296,7 @@ class ImportScreen(ModalScreen[Optional[str]]): height: auto; margin-top: 1; } - ''' + """ BINDINGS = [ Binding('escape', 'cancel', 'cancel'), @@ -305,8 +306,7 @@ class ImportScreen(ModalScreen[Optional[str]]): with Vertical(id='import-dialog') as dialog: dialog.border_title = 'Import thread from lore' yield Input(placeholder='Message-ID or lore URL', id='import-msgid') - yield Checkbox('Ignore parent messages in thread', - id='import-noparent') + yield Checkbox('Ignore parent messages in thread', id='import-noparent') yield Static('', id='import-status') def on_input_submitted(self, event: Input.Submitted) -> None: @@ -325,11 +325,14 @@ class ImportScreen(ModalScreen[Optional[str]]): status.update('Importing...') self.run_worker( lambda: self._do_import(msgid, noparent), - name='import', thread=True, exit_on_error=False, + name='import', + thread=True, + exit_on_error=False, ) def _do_import(self, msgid: str, noparent: bool) -> str: from b4.bugs._import import import_thread + app = self.app if not isinstance(app, BugListApp): raise RuntimeError('ImportScreen must be used with BugListApp') @@ -337,9 +340,7 @@ class ImportScreen(ModalScreen[Optional[str]]): with _quiet_worker(): bug = import_thread(app.repo, msgid, noparent=noparent) except RuntimeError: - raise RuntimeError( - 'Could not retrieve message thread' - ) from None + raise RuntimeError('Could not retrieve message thread') from None return bug.id async def on_worker_state_changed(self, event: Worker.StateChanged) -> None: @@ -366,6 +367,7 @@ class CommentItem(ListItem): def compose(self) -> ComposeResult: from textual.widgets import Label + st = Label(f' {self._display_name}', markup=False) st.styles.text_style = 'dim' yield st @@ -374,7 +376,7 @@ class CommentItem(ListItem): class BugDetailScreen(ModalScreen[None]): """Full-screen bug detail view with left pane navigation.""" - DEFAULT_CSS = ''' + DEFAULT_CSS = """ BugDetailScreen { background: $surface; } @@ -419,7 +421,7 @@ class BugDetailScreen(ModalScreen[None]): #detail-log { width: 3fr; } - ''' + """ BINDINGS = [ Binding('a', 'bug_action', 'action'), @@ -469,8 +471,7 @@ class BugDetailScreen(ModalScreen[None]): with Horizontal(id='detail-body'): with Vertical(id='comment-list-pane'): yield ListView(id='comment-list') - yield RichLog(id='detail-log', wrap=True, markup=False, - auto_scroll=False) + yield RichLog(id='detail-log', wrap=True, markup=False, auto_scroll=False) yield SeparatedFooter() def on_mount(self) -> None: @@ -479,10 +480,17 @@ class BugDetailScreen(ModalScreen[None]): self._ts = app._ts # Build colour map for commenters - palette = reviewer_colours(self._ts) if self._ts else [ - 'dark_goldenrod', 'dark_cyan', 'dark_magenta', - 'dark_red', 'dark_blue', - ] + palette = ( + reviewer_colours(self._ts) + if self._ts + else [ + 'dark_goldenrod', + 'dark_cyan', + 'dark_magenta', + 'dark_red', + 'dark_blue', + ] + ) emails: list[str] = [] for comment in self.bug.comments: addr = '' @@ -509,15 +517,15 @@ class BugDetailScreen(ModalScreen[None]): if parent_idx is not None: parent_depth = self._comment_depths.get(parent_idx, 0) self._comment_depths[i] = min( - parent_depth + 1, self._MAX_DEPTH, + parent_depth + 1, + self._MAX_DEPTH, ) continue self._comment_depths[i] = 0 # Build visible comment indices (skip removed) self._visible_indices = [ - i for i, c in enumerate(self.bug.comments) - if not is_comment_removed(c.text) + i for i, c in enumerate(self.bug.comments) if not is_comment_removed(c.text) ] # Populate left pane @@ -530,6 +538,7 @@ class BugDetailScreen(ModalScreen[None]): def _initial_populate() -> None: self._populate_richlog() self.query_one('#comment-list', ListView).focus() + self.call_after_refresh(_initial_populate) def _build_comment_items(self) -> list[CommentItem]: @@ -571,8 +580,7 @@ class BugDetailScreen(ModalScreen[None]): subsequent appends, causing stale child counts. """ self._visible_indices = [ - i for i, c in enumerate(self.bug.comments) - if not is_comment_removed(c.text) + i for i, c in enumerate(self.bug.comments) if not is_comment_removed(c.text) ] # Replace the ListView widget and rebuild the RichLog in a # single batch so the screen doesn't flicker mid-rebuild. @@ -600,7 +608,10 @@ class BugDetailScreen(ModalScreen[None]): return self._ts.get('accent', 'cyan') def _render_comment_panel( - self, viewer: RichLog, comment: Comment, idx: int, + self, + viewer: RichLog, + comment: Comment, + idx: int, depth: int = 0, ) -> None: """Render a comment as a bordered panel in the review app style.""" @@ -611,7 +622,7 @@ class BugDetailScreen(ModalScreen[None]): header_block, body = text, '' colour = self._get_comment_colour(comment) - bg = f"on {self._ts['panel']}" if self._ts.get('panel') else 'on grey11' + bg = f'on {self._ts["panel"]}' if self._ts.get('panel') else 'on grey11' # Extract sender name for panel title from_hdr = parse_comment_header(text, 'From') @@ -633,7 +644,7 @@ class BugDetailScreen(ModalScreen[None]): colon = line.find(':') if colon > 0: hdr_name = line[:colon] - hdr_val = line[colon + 1:].strip() + hdr_val = line[colon + 1 :].strip() if hdr_name == 'In-Reply-To': continue if hdr_name == 'Message-ID': @@ -672,6 +683,7 @@ class BugDetailScreen(ModalScreen[None]): ) if depth > 0: from rich.padding import Padding + viewer.write(Padding(panel, pad=(0, 0, 0, depth * 2))) else: viewer.write(panel) @@ -802,13 +814,15 @@ class BugDetailScreen(ModalScreen[None]): with self.app.suspend(): try: result = b4.edit_in_editor( - template.encode(), filehint='bug-comment.md', + template.encode(), + filehint='bug-comment.md', ) except Exception as exc: logger.critical('Editor error: %s', exc) return # Strip HTML comments and check if anything remains import re + text = result.decode(errors='replace') text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL).strip() if not text: @@ -825,7 +839,8 @@ class BugDetailScreen(ModalScreen[None]): """Return the currently selected comment, or None.""" lv = self.query_one('#comment-list', ListView) if lv.highlighted_child is not None and isinstance( - lv.highlighted_child, CommentItem, + lv.highlighted_child, + CommentItem, ): idx = lv.highlighted_child.comment_idx if idx < len(self.bug.comments): @@ -841,8 +856,9 @@ class BugDetailScreen(ModalScreen[None]): # Get message-id from comment — required for reply msgid = parse_comment_header(comment.text, 'Message-ID') if not msgid: - self.notify('No Message-ID in this comment, cannot reply', - severity='warning') + self.notify( + 'No Message-ID in this comment, cannot reply', severity='warning' + ) return # Fetch the original message from lore @@ -857,10 +873,12 @@ class BugDetailScreen(ModalScreen[None]): """Fetch the original message and compose a reply.""" # Determine if this bug uses --no-parent scope scope = parse_comment_header( - self.bug.comments[0].text, 'X-B4-Bug-Scope', + self.bug.comments[0].text, + 'X-B4-Bug-Scope', ) root_msgid = parse_comment_header( - self.bug.comments[0].text, 'Message-ID', + self.bug.comments[0].text, + 'Message-ID', ) if not root_msgid: root_msgid = msgid @@ -871,7 +889,8 @@ class BugDetailScreen(ModalScreen[None]): msgs = b4.get_pi_thread_by_msgid(fetch_id) if not msgs: self.app.call_from_thread( - self.notify, 'Could not retrieve thread from lore', + self.notify, + 'Could not retrieve thread from lore', severity='error', ) return @@ -879,7 +898,9 @@ class BugDetailScreen(ModalScreen[None]): # Apply --no-parent filter if applicable if scope == 'no-parent': filtered = b4.get_strict_thread( - msgs, fetch_id, noparent=True, + msgs, + fetch_id, + noparent=True, ) if filtered: msgs = filtered @@ -896,7 +917,8 @@ class BugDetailScreen(ModalScreen[None]): if target_msg is None: self.app.call_from_thread( - self.notify, f'Message {msgid} not found in thread', + self.notify, + f'Message {msgid} not found in thread', severity='error', ) return @@ -922,21 +944,23 @@ class BugDetailScreen(ModalScreen[None]): # Schedule the editor open on the main thread self.app.call_from_thread( - self._open_reply_editor, lmsg, reply_text, + self._open_reply_editor, + lmsg, + reply_text, ) - def _open_reply_editor(self, lmsg: 'b4.LoreMessage', - reply_text: str) -> None: + def _open_reply_editor(self, lmsg: 'b4.LoreMessage', reply_text: str) -> None: """Open editor and show preview (runs on main thread).""" self._reply_edit_loop(lmsg, reply_text) - def _reply_edit_loop(self, lmsg: 'b4.LoreMessage', - reply_text: str, - is_reedit: bool = False) -> None: + def _reply_edit_loop( + self, lmsg: 'b4.LoreMessage', reply_text: str, is_reedit: bool = False + ) -> None: with self.app.suspend(): try: result = b4.edit_in_editor( - reply_text.encode(), filehint='reply.eml', + reply_text.encode(), + filehint='reply.eml', ) except Exception as exc: logger.critical('Editor error: %s', exc) @@ -960,7 +984,8 @@ class BugDetailScreen(ModalScreen[None]): self._reply_edit_loop(lmsg, final_text, is_reedit=True) self.app.push_screen( - ReplyPreviewScreen(reply_msg), callback=_on_preview, + ReplyPreviewScreen(reply_msg), + callback=_on_preview, ) def _send_reply(self, msg: 'email.message.EmailMessage') -> None: @@ -973,7 +998,8 @@ class BugDetailScreen(ModalScreen[None]): try: smtp, fromaddr = b4.get_smtp(dryrun=dryrun) sent = b4.send_mail( - smtp, [msg], + smtp, + [msg], fromaddr=fromaddr, patatt_sign=patatt_sign, dryrun=dryrun, @@ -990,6 +1016,7 @@ class BugDetailScreen(ModalScreen[None]): self.notify('Reply sent') # Record the reply as a comment on the bug from b4.bugs._import import format_comment + comment_text = format_comment(msg) app.repo.add_comment(self.bug.id, comment_text) app.repo.invalidate(self.bug.id) @@ -1021,8 +1048,9 @@ class BugDetailScreen(ModalScreen[None]): if not isinstance(app, BugListApp): return usercfg = b4.get_user_config() - identity = (f'{usercfg.get("name", "Unknown")} ' - f'<{usercfg.get("email", "unknown")}>') + identity = ( + f'{usercfg.get("name", "Unknown")} <{usercfg.get("email", "unknown")}>' + ) tombstone = make_tombstone(comment.text, identity) app.repo.edit_comment(self.bug.id, comment.id, tombstone) self._refresh_bug_view() @@ -1032,8 +1060,10 @@ class BugDetailScreen(ModalScreen[None]): self.app.push_screen( ConfirmScreen( title='Remove comment?', - body=[f'From: {sender}', - 'The comment body will be permanently removed.'], + body=[ + f'From: {sender}', + 'The comment body will be permanently removed.', + ], border='$warning', ), callback=_on_confirm, @@ -1041,6 +1071,7 @@ class BugDetailScreen(ModalScreen[None]): def action_edit_title(self) -> None: """Edit the bug title.""" + def _on_result(new_title: Optional[str]) -> None: if not new_title: return @@ -1057,7 +1088,8 @@ class BugDetailScreen(ModalScreen[None]): self.query_one('#detail-header', Static).update(header) self.app.push_screen( - EditTitleScreen(self.bug.title), callback=_on_result, + EditTitleScreen(self.bug.title), + callback=_on_result, ) def action_add_label(self) -> None: @@ -1093,12 +1125,14 @@ class BugDetailScreen(ModalScreen[None]): return bid = self.bug.id if action == 'delete': + def _on_delete(confirmed: bool | None) -> None: if not confirmed: return app.repo.remove_bug(bid) app.repo.invalidate() self.dismiss(None) + self.app.push_screen( ConfirmScreen( title='Delete bug?', @@ -1110,14 +1144,14 @@ class BugDetailScreen(ModalScreen[None]): ) return if action == 'duplicate': + def _on_dup(target_id: Optional[str]) -> None: if not target_id: return target = app.repo.get_bug(target_id) app.repo.add_comment( bid, - f'Closing as duplicate of {target.id[:7]}: ' - f'{target.title}', + f'Closing as duplicate of {target.id[:7]}: {target.title}', ) for lb in self.bug.labels: if lb.startswith('lifecycle:'): @@ -1126,6 +1160,7 @@ class BugDetailScreen(ModalScreen[None]): app.repo.set_status(bid, Status.CLOSED) app.repo.invalidate() self.dismiss(None) + self.app.push_screen( DuplicateScreen(app.repo, self.bug), callback=_on_dup, @@ -1151,8 +1186,8 @@ class BugDetailScreen(ModalScreen[None]): self._refresh_bug_view() self.app.push_screen( - ActionScreen(actions, shortcuts=_ACTION_SHORTCUTS), - callback=_on_result) + ActionScreen(actions, shortcuts=_ACTION_SHORTCUTS), callback=_on_result + ) def action_back(self) -> None: self.dismiss(None) @@ -1164,7 +1199,7 @@ class ReplyPreviewScreen(ModalScreen[Optional[str]]): Returns 'send' to send, 'edit' to re-edit, or None to abandon. """ - DEFAULT_CSS = ''' + DEFAULT_CSS = """ ReplyPreviewScreen { background: $surface; } @@ -1183,7 +1218,7 @@ class ReplyPreviewScreen(ModalScreen[Optional[str]]): padding: 0 1; color: $text-muted; } - ''' + """ BINDINGS = [ Binding('S', 'send', 'Send'), @@ -1242,6 +1277,7 @@ class ReplyPreviewScreen(ModalScreen[Optional[str]]): def action_edit_tocc(self) -> None: from b4.tui import ToCcScreen + to_val = b4.LoreMessage.clean_header(self._msg.get('To', '')) cc_val = b4.LoreMessage.clean_header(self._msg.get('Cc', '')) screen = ToCcScreen(to_val, cc_val, '', show_apply_all=False) @@ -1281,7 +1317,6 @@ class ReplyPreviewScreen(ModalScreen[Optional[str]]): self.query_one('#reply-preview-log', RichLog).scroll_page_up() - class LabelOption(ListItem): """A toggleable label option in the label selection dialog.""" @@ -1292,6 +1327,7 @@ class LabelOption(ListItem): def compose(self) -> ComposeResult: from textual.widgets import Label + mark = 'x' if self.selected else ' ' text = Text() text.append(f'[{mark}] ') @@ -1302,6 +1338,7 @@ class LabelOption(ListItem): def toggle(self) -> None: self.selected = not self.selected from textual.widgets import Label + mark = 'x' if self.selected else ' ' text = Text() text.append(f'[{mark}] ') @@ -1322,7 +1359,7 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): _list_id = '#label-list' - DEFAULT_CSS = ''' + DEFAULT_CSS = """ LabelScreen { align: center middle; } @@ -1342,7 +1379,7 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): margin-top: 1; color: $text-muted; } - ''' + """ BINDINGS = [ Binding('space', 'toggle_item', 'Toggle', show=False), @@ -1355,7 +1392,8 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): ] def __init__( - self, current_labels: set[str] | frozenset[str], + self, + current_labels: set[str] | frozenset[str], suggestions: list[str], ) -> None: super().__init__() @@ -1365,10 +1403,7 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): self._suggestions = suggestions def compose(self) -> ComposeResult: - items = [ - LabelOption(lb, initially_selected=True) - for lb in self._current - ] + items = [LabelOption(lb, initially_selected=True) for lb in self._current] with Vertical(id='label-dialog') as dialog: dialog.border_title = 'Select labels' if not items: @@ -1381,7 +1416,9 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): else: yield ListView(*items, id='label-list') yield Static( - Text('space toggle | [a] add new | Enter save | Escape cancel'), + Text( + 'space toggle | [a] add new | Enter save | Escape cancel' + ), id='label-hint', ) @@ -1391,7 +1428,8 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): def action_toggle_item(self) -> None: lv = self.query_one('#label-list', ListView) if lv.highlighted_child is not None and isinstance( - lv.highlighted_child, LabelOption, + lv.highlighted_child, + LabelOption, ): lv.highlighted_child.toggle() @@ -1405,7 +1443,9 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): has_items = len(lv.children) > 0 hint = self.query_one('#label-hint', Static) if has_items: - hint.update(Text('space toggle | [a] add new | Enter save | Escape cancel')) + hint.update( + Text('space toggle | [a] add new | Enter save | Escape cancel') + ) else: hint.update(Text('[a] add new | Escape cancel')) @@ -1425,6 +1465,7 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): lv.index = len(lv.children) - 1 lv.focus() self._update_hint() + self.app.push_screen( AddLabelScreen(self._suggestions), callback=_on_added, @@ -1457,7 +1498,7 @@ class LabelScreen(JKListNavMixin, ModalScreen[Optional[dict[str, list[str]]]]): class AddLabelScreen(ModalScreen[Optional[str]]): """Text input for adding a brand-new label, with suggestions.""" - DEFAULT_CSS = ''' + DEFAULT_CSS = """ AddLabelScreen { align: center middle; } @@ -1468,7 +1509,7 @@ class AddLabelScreen(ModalScreen[Optional[str]]): background: $surface; padding: 1 2; } - ''' + """ BINDINGS = [ Binding('escape', 'cancel', 'cancel'), @@ -1481,7 +1522,8 @@ class AddLabelScreen(ModalScreen[Optional[str]]): def compose(self) -> ComposeResult: suggester = ( SuggestFromList(self._suggestions, case_sensitive=False) - if self._suggestions else None + if self._suggestions + else None ) with Vertical(id='addlabel-dialog') as dialog: dialog.border_title = 'Add label' @@ -1505,21 +1547,21 @@ class AddLabelScreen(ModalScreen[Optional[str]]): # Shortcut keys for the bug action selector. _ACTION_SHORTCUTS: dict[str, str] = { - 'confirmed': 'c', - 'needinfo': 'n', + 'confirmed': 'c', + 'needinfo': 'n', 'worksforme': 'w', - 'wontfix': 'x', - 'fixed': 'f', - 'duplicate': 'd', - 'reopen': 'r', - 'delete': 'D', + 'wontfix': 'x', + 'fixed': 'f', + 'duplicate': 'd', + 'reopen': 'r', + 'delete': 'D', } class UpdateBugsScreen(ModalScreen[Optional[dict[str, int]]]): """Modal showing progress while updating bugs from lore.""" - DEFAULT_CSS = ''' + DEFAULT_CSS = """ UpdateBugsScreen { align: center middle; } @@ -1537,7 +1579,7 @@ class UpdateBugsScreen(ModalScreen[Optional[dict[str, int]]]): margin-top: 1; color: $text-muted; } - ''' + """ BINDINGS = [ Binding('escape', 'cancel', 'cancel'), @@ -1550,11 +1592,14 @@ class UpdateBugsScreen(ModalScreen[Optional[dict[str, int]]]): self._repo = repo self._cancelled = False self._result: dict[str, int] = { - 'checked': 0, 'updated': 0, 'new_comments': 0, + 'checked': 0, + 'updated': 0, + 'new_comments': 0, } def compose(self) -> ComposeResult: from textual.widgets import Label, ProgressBar + count = len(self._bugs) title = f'Updating {count} bug(s)' if count > 1 else 'Updating bug' with Vertical(id='update-dialog') as dialog: @@ -1565,16 +1610,21 @@ class UpdateBugsScreen(ModalScreen[Optional[dict[str, int]]]): ) yield Label('', id='update-bug', markup=False) yield ProgressBar( - total=count, show_eta=False, id='update-progress', + total=count, + show_eta=False, + id='update-progress', ) def on_mount(self) -> None: self.run_worker( - self._do_updates, name='_do_updates', thread=True, + self._do_updates, + name='_do_updates', + thread=True, ) def _update_progress(self, completed: int, title: str) -> None: from textual.widgets import Label, ProgressBar + count = len(self._bugs) self.query_one('#update-status', Label).update( f'Checking {completed}/{count} bugs...', @@ -1584,12 +1634,15 @@ class UpdateBugsScreen(ModalScreen[Optional[dict[str, int]]]): def _do_updates(self) -> dict[str, int]: from b4.bugs._import import refresh_bug + with _quiet_worker(): for i, bug in enumerate(self._bugs): if self._cancelled: break self.app.call_from_thread( - self._update_progress, i, bug.title, + self._update_progress, + i, + bug.title, ) try: count = refresh_bug(self._repo, bug.id) @@ -1600,12 +1653,15 @@ class UpdateBugsScreen(ModalScreen[Optional[dict[str, int]]]): self._result['updated'] += 1 self._result['new_comments'] += count self.app.call_from_thread( - self._update_progress, i + 1, bug.title, + self._update_progress, + i + 1, + bug.title, ) return self._result async def on_worker_state_changed( - self, event: Worker.StateChanged, + self, + event: Worker.StateChanged, ) -> None: if event.worker.name != '_do_updates': return @@ -1624,7 +1680,7 @@ class DuplicateScreen(ModalScreen[Optional[str]]): Returns the resolved bug ID on confirm, or None on cancel. """ - DEFAULT_CSS = ''' + DEFAULT_CSS = """ DuplicateScreen { align: center middle; } @@ -1640,7 +1696,7 @@ class DuplicateScreen(ModalScreen[Optional[str]]): margin-top: 1; color: $text-muted; } - ''' + """ BINDINGS = [ Binding('escape', 'cancel', 'cancel'), @@ -1655,8 +1711,7 @@ class DuplicateScreen(ModalScreen[Optional[str]]): with Vertical(id='dup-dialog') as dialog: dialog.border_title = 'Close as duplicate of' yield Input(placeholder='Bug ID', id='dup-input') - yield Static('Enter confirm | Escape cancel', - id='dup-status') + yield Static('Enter confirm | Escape cancel', id='dup-status') def on_mount(self) -> None: self.query_one('#dup-input', Input).focus() @@ -1685,7 +1740,7 @@ class DuplicateScreen(ModalScreen[Optional[str]]): class EditTitleScreen(ModalScreen[Optional[str]]): """Edit a bug's title.""" - DEFAULT_CSS = ''' + DEFAULT_CSS = """ EditTitleScreen { align: center middle; } @@ -1700,7 +1755,7 @@ class EditTitleScreen(ModalScreen[Optional[str]]): margin-top: 1; color: $text-muted; } - ''' + """ BINDINGS = [ Binding('escape', 'cancel', 'cancel'), @@ -1714,8 +1769,7 @@ class EditTitleScreen(ModalScreen[Optional[str]]): with Vertical(id='edit-title-dialog') as dialog: dialog.border_title = 'Edit title' yield Input(value=self._current_title, id='edit-title-input') - yield Static('Enter save | Escape cancel', - id='edit-title-hint') + yield Static('Enter save | Escape cancel', id='edit-title-hint') def on_mount(self) -> None: self.query_one('#edit-title-input', Input).focus() @@ -1733,6 +1787,7 @@ class EditTitleScreen(ModalScreen[Optional[str]]): # -- Main app ---------------------------------------------------------------- + class BugListApp(JKListNavMixin, App[None]): """Bug management TUI backed by git-bug via ezgb.""" @@ -1740,7 +1795,7 @@ class BugListApp(JKListNavMixin, App[None]): _list_id = '#bug-list' - DEFAULT_CSS = ''' + DEFAULT_CSS = """ BugListApp { layout: vertical; } @@ -1780,7 +1835,7 @@ class BugListApp(JKListNavMixin, App[None]): width: 14; text-style: bold; } - ''' + """ BINDINGS = [ Binding('j', 'cursor_down', 'down', show=False), @@ -1812,9 +1867,9 @@ class BugListApp(JKListNavMixin, App[None]): 'action_quit': 'global', } - def __init__(self, repo: GitBugRepo, *, - email_dryrun: bool = False, - no_sign: bool = False) -> None: + def __init__( + self, repo: GitBugRepo, *, email_dryrun: bool = False, no_sign: bool = False + ) -> None: super().__init__() self.repo = repo self.email_dryrun = email_dryrun @@ -1832,7 +1887,9 @@ class BugListApp(JKListNavMixin, App[None]): def compose(self) -> ComposeResult: yield Static('b4 bugs', id='title-bar') - header_text = f'{"ID":<7s} {"Submitter":<20s} {"Msgs":>4s} {"S"} {"Subject"}' + header_text = ( + f'{"ID":<7s} {"Submitter":<20s} {"Msgs":>4s} {"S"} {"Subject"}' + ) yield Static(header_text, id='column-header') yield ListView(id='bug-list') with Vertical(id='details-panel'): @@ -1882,8 +1939,7 @@ class BugListApp(JKListNavMixin, App[None]): if current_mtime != self._cache_mtime: self._cache_mtime = current_mtime self._save_focus() - self.run_worker(self._load_bugs, name='load_bugs', - thread=True) + self.run_worker(self._load_bugs, name='load_bugs', thread=True) def on_mount(self) -> None: self._ts = resolve_styles(self) @@ -1947,20 +2003,18 @@ class BugListApp(JKListNavMixin, App[None]): # or the limit pattern has an explicit s: token has_explicit_status = self._has_status_token(self._limit_pattern) if not self._show_closed and not has_explicit_status: - display_bugs = [ - b for b in display_bugs if b.status == Status.OPEN - ] + display_bugs = [b for b in display_bugs if b.status == Status.OPEN] if self._limit_pattern: display_bugs = [ - b for b in display_bugs - if self._matches_limit(b, self._limit_pattern) + b for b in display_bugs if self._matches_limit(b, self._limit_pattern) ] # Sort: by last activity (newest first) within each tier, # then by tier (active → waiting → resolved). display_bugs.sort( - key=_bug_last_activity, reverse=True, + key=_bug_last_activity, + reverse=True, ) display_bugs.sort(key=_bug_tier) @@ -1968,7 +2022,9 @@ class BugListApp(JKListNavMixin, App[None]): items: list[BugListItem] = [] for bug in display_bugs: - count = bug.comment_count if isinstance(bug, BugSummary) else len(bug.comments) + count = ( + bug.comment_count if isinstance(bug, BugSummary) else len(bug.comments) + ) seen = self._seen_counts.get(bug.id, count) unseen = max(0, count - seen) items.append(BugListItem(bug, unseen=unseen)) @@ -1999,7 +2055,11 @@ class BugListApp(JKListNavMixin, App[None]): # new comments that arrived since the baseline. for bug in self._all_bugs: if bug.id not in self._seen_counts: - count = bug.comment_count if isinstance(bug, BugSummary) else len(bug.comments) + count = ( + bug.comment_count + if isinstance(bug, BugSummary) + else len(bug.comments) + ) self._seen_counts[bug.id] = count # Collect all known labels across all bugs all_labels: set[str] = set() @@ -2023,7 +2083,7 @@ class BugListApp(JKListNavMixin, App[None]): lifecycle = '' for lb in bug.labels: if lb.startswith('lifecycle:'): - lifecycle = lb[len('lifecycle:'):] + lifecycle = lb[len('lifecycle:') :] break git_status = 'open' if bug.status == Status.OPEN else 'closed' if lifecycle: @@ -2050,15 +2110,13 @@ class BugListApp(JKListNavMixin, App[None]): self.query_one('#detail-labels', Static).update('none') created_str = ( - f'{bug.created_at:%Y-%m-%d %H:%M} ' - f'({_relative_time(bug.created_at)})' + f'{bug.created_at:%Y-%m-%d %H:%M} ({_relative_time(bug.created_at)})' ) self.query_one('#detail-created', Static).update(created_str) if isinstance(bug, BugSummary): edited_str = ( - f'{bug.edited_at:%Y-%m-%d %H:%M} ' - f'({_relative_time(bug.edited_at)})' + f'{bug.edited_at:%Y-%m-%d %H:%M} ({_relative_time(bug.edited_at)})' ) self.query_one('#detail-last-activity', Static).update(edited_str) self.query_one('#detail-comments', Static).update( @@ -2072,8 +2130,7 @@ class BugListApp(JKListNavMixin, App[None]): f'by {last.author.name}' ) self.query_one('#detail-last-activity', Static).update(last_str) - visible = sum(1 for c in bug.comments - if not is_comment_removed(c.text)) + visible = sum(1 for c in bug.comments if not is_comment_removed(c.text)) self.query_one('#detail-comments', Static).update( str(visible), ) @@ -2090,7 +2147,11 @@ class BugListApp(JKListNavMixin, App[None]): # Mark as seen so the badge clears on return bug_id = event.item.bug.id bug_obj = event.item.bug - count = bug_obj.comment_count if isinstance(bug_obj, BugSummary) else len(bug_obj.comments) + count = ( + bug_obj.comment_count + if isinstance(bug_obj, BugSummary) + else len(bug_obj.comments) + ) self._seen_counts[bug_id] = count # Load full Bug on demand (CachedBug doesn't have comments) bug = self.repo.get_bug(bug_id) @@ -2098,17 +2159,17 @@ class BugListApp(JKListNavMixin, App[None]): def _on_dismiss(_result: None) -> None: self.repo.invalidate() self._save_focus() - self.run_worker(self._load_bugs, name='load_bugs', - thread=True) + self.run_worker(self._load_bugs, name='load_bugs', thread=True) - self.push_screen(BugDetailScreen(bug), - callback=_on_dismiss) + self.push_screen(BugDetailScreen(bug), callback=_on_dismiss) # -- Actions ------------------------------------------------------------- def _get_selected_bug(self) -> Optional[BugLike]: lv = self.query_one('#bug-list', ListView) - if lv.highlighted_child is not None and isinstance(lv.highlighted_child, BugListItem): + if lv.highlighted_child is not None and isinstance( + lv.highlighted_child, BugListItem + ): return lv.highlighted_child.bug return None @@ -2125,9 +2186,11 @@ class BugListApp(JKListNavMixin, App[None]): def action_limit(self) -> None: self.push_screen( - LimitScreen(self._limit_pattern, - hint='Prefixes: s:<status> l:<label>', - title='Limit bugs'), + LimitScreen( + self._limit_pattern, + hint='Prefixes: s:<status> l:<label>', + title='Limit bugs', + ), callback=self._on_limit, ) @@ -2161,8 +2224,8 @@ class BugListApp(JKListNavMixin, App[None]): if result: self._focus_bug_id = result self.repo.invalidate() - self.run_worker(self._load_bugs, name='load_bugs', - thread=True) + self.run_worker(self._load_bugs, name='load_bugs', thread=True) + self.push_screen(ImportScreen(), callback=_on_result) def _do_create_new_bug(self) -> None: @@ -2175,12 +2238,14 @@ class BugListApp(JKListNavMixin, App[None]): with self.suspend(): try: result = b4.edit_in_editor( - template.encode(), filehint='new-bug.md', + template.encode(), + filehint='new-bug.md', ) except Exception as exc: logger.critical('Editor error: %s', exc) return import re + text = result.decode(errors='replace') text = re.sub(r'<!--.*?-->', '', text, flags=re.DOTALL).strip() if not text: @@ -2195,11 +2260,11 @@ class BugListApp(JKListNavMixin, App[None]): bug = self.repo.create_bug(title, body) self._focus_bug_id = bug.id self.repo.invalidate() - self.run_worker(self._load_bugs, name='load_bugs', - thread=True) + self.run_worker(self._load_bugs, name='load_bugs', thread=True) def _on_update_complete( - self, result: Optional[dict[str, int]], + self, + result: Optional[dict[str, int]], ) -> None: if result: updated = result.get('updated', 0) @@ -2281,8 +2346,7 @@ class BugListApp(JKListNavMixin, App[None]): for lb in result.get('add', []): self.repo.add_label(bug.id, lb) self.repo.invalidate() - self.run_worker(self._load_bugs, name='load_bugs', - thread=True) + self.run_worker(self._load_bugs, name='load_bugs', thread=True) self.push_screen( LabelScreen(bug.labels, self._known_labels), @@ -2306,7 +2370,7 @@ class BugListApp(JKListNavMixin, App[None]): lifecycle = 'new' for lb in bug.labels: if lb.startswith('lifecycle:'): - lifecycle = lb[len('lifecycle:'):] + lifecycle = lb[len('lifecycle:') :] break actions: list[tuple[str, str]] = [] @@ -2329,12 +2393,14 @@ class BugListApp(JKListNavMixin, App[None]): ('needinfo', 'Need info'), ] # Close reasons are always available regardless of lifecycle - actions.extend([ - ('fixed', 'Close: fixed'), - ('worksforme', 'Close: works for me'), - ('wontfix', "Close: won't fix"), - ('duplicate', 'Close: duplicate of\u2026'), - ]) + actions.extend( + [ + ('fixed', 'Close: fixed'), + ('worksforme', 'Close: works for me'), + ('wontfix', "Close: won't fix"), + ('duplicate', 'Close: duplicate of\u2026'), + ] + ) actions.append(('delete', 'Delete bug')) return actions @@ -2347,8 +2413,9 @@ class BugListApp(JKListNavMixin, App[None]): def _on_result(action: Optional[str]) -> None: self._apply_action(bug, action) - self.push_screen(ActionScreen(actions, shortcuts=_ACTION_SHORTCUTS), - callback=_on_result) + self.push_screen( + ActionScreen(actions, shortcuts=_ACTION_SHORTCUTS), callback=_on_result + ) # Lifecycle states that close the bug _CLOSING_STATES = {'worksforme', 'wontfix', 'fixed', 'duplicate'} @@ -2390,8 +2457,8 @@ class BugListApp(JKListNavMixin, App[None]): if confirmed and bug: self.repo.remove_bug(bug.id) self.repo.invalidate() - self.run_worker(self._load_bugs, name='load_bugs', - thread=True) + self.run_worker(self._load_bugs, name='load_bugs', thread=True) + self.push_screen( ConfirmScreen( title='Delete bug?', @@ -2419,11 +2486,9 @@ class BugListApp(JKListNavMixin, App[None]): self.repo.add_label(bid, 'lifecycle:duplicate') self.repo.set_status(bid, Status.CLOSED) self.repo.invalidate() - self.run_worker(self._load_bugs, name='load_bugs', - thread=True) + self.run_worker(self._load_bugs, name='load_bugs', thread=True) self.push_screen( - DuplicateScreen(self.repo, bug), callback=_on_result, + DuplicateScreen(self.repo, bug), + callback=_on_result, ) - - diff --git a/src/b4/command.py b/src/b4/command.py index 7ebe79c..35ed44b 100644 --- a/src/b4/command.py +++ b/src/b4/command.py @@ -16,134 +16,263 @@ logger = b4.logger def cmd_retrieval_common_opts(sp: argparse.ArgumentParser) -> None: - sp.add_argument('msgid', nargs='?', - help='Message ID to process, or pipe a raw message') - sp.add_argument('-m', '--use-local-mbox', dest='localmbox', default=None, - help='Instead of grabbing a thread from lore, process this mbox file (or - for stdin)') - sp.add_argument('--stdin-pipe-sep', - help='When accepting messages on stdin, split using this pipe separator string') - sp.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False, - help='Do not use local cache') - sp.add_argument('--single-message', dest='singlemsg', action='store_true', default=False, - help='Only retrieve the message matching the msgid and ignore the rest of the thread') - sp.add_argument('--rethread', nargs='+', metavar='MSGID', default=None, - help='Rethread multiple unrelated messages into a single series ' - '(pass - to read message IDs from stdin, one per line)') + sp.add_argument( + 'msgid', nargs='?', help='Message ID to process, or pipe a raw message' + ) + sp.add_argument( + '-m', + '--use-local-mbox', + dest='localmbox', + default=None, + help='Instead of grabbing a thread from lore, process this mbox file (or - for stdin)', + ) + sp.add_argument( + '--stdin-pipe-sep', + help='When accepting messages on stdin, split using this pipe separator string', + ) + sp.add_argument( + '-C', + '--no-cache', + dest='nocache', + action='store_true', + default=False, + help='Do not use local cache', + ) + sp.add_argument( + '--single-message', + dest='singlemsg', + action='store_true', + default=False, + help='Only retrieve the message matching the msgid and ignore the rest of the thread', + ) + sp.add_argument( + '--rethread', + nargs='+', + metavar='MSGID', + default=None, + help='Rethread multiple unrelated messages into a single series ' + '(pass - to read message IDs from stdin, one per line)', + ) def cmd_mbox_common_opts(sp: argparse.ArgumentParser) -> None: 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)') + sp.add_argument( + '-o', + '--outdir', + default='.', + help='Output into this directory (or use - to output mailbox contents to stdout)', + ) + sp.add_argument( + '-c', + '--check-newer-revisions', + dest='checknewer', + action='store_true', + default=False, + help='Check if newer patch revisions exist', + ) + sp.add_argument( + '-n', + '--mbox-name', + dest='wantname', + default=None, + help='Filename to name the mbox destination', + ) + sp.add_argument( + '-M', + '--save-as-maildir', + dest='maildir', + action='store_true', + default=False, + help='Save as maildir (avoids mbox format ambiguities)', + ) def cmd_am_common_opts(sp: argparse.ArgumentParser) -> None: - sp.add_argument('-v', '--use-version', dest='wantver', type=int, default=None, - help='Get a specific version of the patch/series') - sp.add_argument('-t', '--apply-cover-trailers', dest='covertrailers', action='store_true', default=False, - help='(This is now the default behavior; this option will be removed in the future.)') - sp.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False, - help='Apply trailers without email address match checking') - sp.add_argument('-T', '--no-add-trailers', dest='noaddtrailers', action='store_true', default=False, - help='Do not add any trailers from follow-up messages') - sp.add_argument('-s', '--add-my-sob', dest='addmysob', action='store_true', default=False, - help='Add your own signed-off-by to every patch') - sp.add_argument('-P', '--cherry-pick', dest='cherrypick', default=None, - help='Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", ' - '"-P _" to use just the msgid specified, or ' - '"-P *globbing*" to match on commit subject)') - sp.add_argument('-k', '--check', action='store_true', default=False, - help='Run local checks for every patch (e.g. checkpatch)') - sp.add_argument('--cc-trailers', dest='copyccs', action='store_true', default=False, - help='Copy all Cc\'d addresses into Cc: trailers') - sp.add_argument('--no-parent', dest='noparent', action='store_true', default=False, - help='Break thread at the msgid specified and ignore any parent messages') - sp.add_argument('--allow-unicode-control-chars', dest='allowbadchars', action='store_true', default=False, - help='Allow unicode control characters (very rarely legitimate)') + sp.add_argument( + '-v', + '--use-version', + dest='wantver', + type=int, + default=None, + help='Get a specific version of the patch/series', + ) + sp.add_argument( + '-t', + '--apply-cover-trailers', + dest='covertrailers', + action='store_true', + default=False, + help='(This is now the default behavior; this option will be removed in the future.)', + ) + sp.add_argument( + '-S', + '--sloppy-trailers', + dest='sloppytrailers', + action='store_true', + default=False, + help='Apply trailers without email address match checking', + ) + sp.add_argument( + '-T', + '--no-add-trailers', + dest='noaddtrailers', + action='store_true', + default=False, + help='Do not add any trailers from follow-up messages', + ) + sp.add_argument( + '-s', + '--add-my-sob', + dest='addmysob', + action='store_true', + default=False, + help='Add your own signed-off-by to every patch', + ) + sp.add_argument( + '-P', + '--cherry-pick', + dest='cherrypick', + default=None, + help='Cherry-pick a subset of patches (e.g. "-P 1-2,4,6-", ' + '"-P _" to use just the msgid specified, or ' + '"-P *globbing*" to match on commit subject)', + ) + sp.add_argument( + '-k', + '--check', + action='store_true', + default=False, + help='Run local checks for every patch (e.g. checkpatch)', + ) + sp.add_argument( + '--cc-trailers', + dest='copyccs', + action='store_true', + default=False, + help="Copy all Cc'd addresses into Cc: trailers", + ) + sp.add_argument( + '--no-parent', + dest='noparent', + action='store_true', + default=False, + help='Break thread at the msgid specified and ignore any parent messages', + ) + sp.add_argument( + '--allow-unicode-control-chars', + dest='allowbadchars', + action='store_true', + default=False, + help='Allow unicode control characters (very rarely legitimate)', + ) sa_g = sp.add_mutually_exclusive_group() - sa_g.add_argument('-l', '--add-link', dest='addlink', action='store_true', default=False, - help='Add a Link: trailer with message-id lookup URL to every patch') - sa_g.add_argument('-i', '--add-message-id', dest='addmsgid', action='store_true', default=False, - help='Add a Message-ID: trailer to every patch') + sa_g.add_argument( + '-l', + '--add-link', + dest='addlink', + action='store_true', + default=False, + help='Add a Link: trailer with message-id lookup URL to every patch', + ) + sa_g.add_argument( + '-i', + '--add-message-id', + dest='addmsgid', + action='store_true', + default=False, + help='Add a Message-ID: trailer to every patch', + ) def cmd_mbox(cmdargs: argparse.Namespace) -> None: import b4.mbox + b4.mbox.main(cmdargs) def cmd_kr(cmdargs: argparse.Namespace) -> None: import b4.kr + b4.kr.main(cmdargs) def cmd_prep(cmdargs: argparse.Namespace) -> None: import b4.ez + b4.ez.cmd_prep(cmdargs) def cmd_trailers(cmdargs: argparse.Namespace) -> None: import b4.ez + b4.ez.cmd_trailers(cmdargs) def cmd_send(cmdargs: argparse.Namespace) -> None: import b4.ez + b4.ez.cmd_send(cmdargs) def cmd_am(cmdargs: argparse.Namespace) -> None: import b4.mbox + b4.mbox.main(cmdargs) def cmd_shazam(cmdargs: argparse.Namespace) -> None: import b4.mbox + b4.mbox.main(cmdargs) def cmd_review(cmdargs: argparse.Namespace) -> None: import b4.review + b4.review.main(cmdargs) def cmd_bugs(cmdargs: argparse.Namespace) -> None: import b4.bugs + b4.bugs.main(cmdargs) def cmd_pr(cmdargs: argparse.Namespace) -> None: import b4.pr + b4.pr.main(cmdargs) def cmd_ty(cmdargs: argparse.Namespace) -> None: import b4.ty + b4.ty.main(cmdargs) def cmd_diff(cmdargs: argparse.Namespace) -> None: import b4.diff + b4.diff.main(cmdargs) def cmd_dig(cmdargs: argparse.Namespace) -> None: import b4.dig + b4.dig.main(cmdargs) class ConfigOption(argparse.Action): """Action class for storing key=value arguments in a dict.""" - def __call__(self, parser: argparse.ArgumentParser, - namespace: argparse.Namespace, - keyval: Union[str, Sequence[Any], None], - option_string: Optional[str] = None) -> None: + + def __call__( + self, + parser: argparse.ArgumentParser, + namespace: argparse.Namespace, + keyval: Union[str, Sequence[Any], None], + option_string: Optional[str] = None, + ) -> None: config = getattr(namespace, self.dest, None) if config is None: @@ -167,26 +296,55 @@ def setup_parser() -> argparse.ArgumentParser: formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument('--version', action='version', version=b4.__VERSION__) - parser.add_argument('-d', '--debug', action='store_true', default=False, - help='Add more debugging info to the output') - parser.add_argument('-q', '--quiet', action='store_true', default=False, - help='Output critical information only') - parser.add_argument('-n', '--no-interactive', action='store_true', default=False, - help='Do not ask any interactive questions') - parser.add_argument('--offline-mode', action='store_true', default=False, - help='Do not perform any network queries') - parser.add_argument('--no-stdin', action='store_true', default=False, - help='Disable TTY detection for stdin') - parser.add_argument('-c', '--config', metavar='NAME=VALUE', action=ConfigOption, - help='''Set config option NAME to VALUE. Override value + parser.add_argument( + '-d', + '--debug', + action='store_true', + default=False, + help='Add more debugging info to the output', + ) + parser.add_argument( + '-q', + '--quiet', + action='store_true', + default=False, + help='Output critical information only', + ) + parser.add_argument( + '-n', + '--no-interactive', + action='store_true', + default=False, + help='Do not ask any interactive questions', + ) + parser.add_argument( + '--offline-mode', + action='store_true', + default=False, + help='Do not perform any network queries', + ) + parser.add_argument( + '--no-stdin', + action='store_true', + default=False, + help='Disable TTY detection for stdin', + ) + parser.add_argument( + '-c', + '--config', + metavar='NAME=VALUE', + action=ConfigOption, + help="""Set config option NAME to VALUE. Override value from config files. NAME is in dotted section.key format. Using NAME= and omitting VALUE will set the value to the empty string. Using NAME and omitting - =VALUE will set the value to "true".''') + =VALUE will set the value to "true".""", + ) try: import shtab - shtab.add_argument_to(parser, ["--print-completion"]) + + shtab.add_argument_to(parser, ['--print-completion']) except ImportError: pass @@ -195,333 +353,889 @@ def setup_parser() -> argparse.ArgumentParser: # b4 mbox sp_mbox = subparsers.add_parser('mbox', help='Download a thread as an mbox file') cmd_mbox_common_opts(sp_mbox) - sp_mbox.add_argument('-f', '--filter-dupes', dest='filterdupes', action='store_true', default=False, - help='When adding messages to existing maildir, filter out duplicates') + sp_mbox.add_argument( + '-f', + '--filter-dupes', + dest='filterdupes', + action='store_true', + default=False, + help='When adding messages to existing maildir, filter out duplicates', + ) sm_g = sp_mbox.add_mutually_exclusive_group() - sm_g.add_argument('-r', '--refetch', dest='refetch', metavar='MBOX', default=False, - help='Refetch all messages in specified mbox with their original headers') - sm_g.add_argument('--minimize', dest='minimize', action='store_true', default=False, - help='Attempt to generate a minimal thread to simplify review.') + sm_g.add_argument( + '-r', + '--refetch', + dest='refetch', + metavar='MBOX', + default=False, + help='Refetch all messages in specified mbox with their original headers', + ) + sm_g.add_argument( + '--minimize', + dest='minimize', + action='store_true', + default=False, + help='Attempt to generate a minimal thread to simplify review.', + ) 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') + sp_am = subparsers.add_parser( + 'am', help='Create an mbox file that is ready to git-am' + ) cmd_mbox_common_opts(sp_am) cmd_am_common_opts(sp_am) - sp_am.add_argument('-Q', '--quilt-ready', dest='quiltready', action='store_true', default=False, - help='Save patches in a quilt-ready folder') - sp_am.add_argument('-g', '--guess-base', dest='guessbase', action='store_true', default=False, - help='Try to guess the base of the series (if not specified)') - sp_am.add_argument('-b', '--guess-branch', dest='guessbranch', nargs='+', action='extend', type=str, default=None, - help='When guessing base, restrict to this branch (use with -g)') - sp_am.add_argument('--guess-lookback', dest='guessdays', type=int, default=21, - help='When guessing base, go back this many days from the patch date (default: 2 weeks)') - sp_am.add_argument('-3', '--prep-3way', dest='threeway', action='store_true', default=False, - help='Prepare for a 3-way merge ' - '(tries to ensure that all index blobs exist by making a fake commit range)') - sp_am.add_argument('--no-cover', dest='nocover', action='store_true', default=False, - help='Do not save the cover letter (on by default when using -o -)') - sp_am.add_argument('--no-partial-reroll', dest='nopartialreroll', action='store_true', default=False, - help='Do not reroll partial series when detected') + sp_am.add_argument( + '-Q', + '--quilt-ready', + dest='quiltready', + action='store_true', + default=False, + help='Save patches in a quilt-ready folder', + ) + sp_am.add_argument( + '-g', + '--guess-base', + dest='guessbase', + action='store_true', + default=False, + help='Try to guess the base of the series (if not specified)', + ) + sp_am.add_argument( + '-b', + '--guess-branch', + dest='guessbranch', + nargs='+', + action='extend', + type=str, + default=None, + help='When guessing base, restrict to this branch (use with -g)', + ) + sp_am.add_argument( + '--guess-lookback', + dest='guessdays', + type=int, + default=21, + help='When guessing base, go back this many days from the patch date (default: 2 weeks)', + ) + sp_am.add_argument( + '-3', + '--prep-3way', + dest='threeway', + action='store_true', + default=False, + help='Prepare for a 3-way merge ' + '(tries to ensure that all index blobs exist by making a fake commit range)', + ) + sp_am.add_argument( + '--no-cover', + dest='nocover', + action='store_true', + default=False, + help='Do not save the cover letter (on by default when using -o -)', + ) + sp_am.add_argument( + '--no-partial-reroll', + dest='nopartialreroll', + action='store_true', + default=False, + help='Do not reroll partial series when detected', + ) sp_am.set_defaults(func=cmd_am) # b4 shazam - sp_sh = subparsers.add_parser('shazam', help='Like b4 am, but applies the series to your tree') + sp_sh = subparsers.add_parser( + 'shazam', help='Like b4 am, but applies the series to your tree' + ) cmd_retrieval_common_opts(sp_sh) cmd_am_common_opts(sp_sh) sh_g = sp_sh.add_mutually_exclusive_group() - sh_g.add_argument('-H', '--make-fetch-head', dest='makefetchhead', action='store_true', default=False, - help='Attempt to treat series as a pull request and fetch it into FETCH_HEAD') - sh_g.add_argument('-M', '--merge', dest='merge', action='store_true', default=False, - help='Attempt to merge series as if it were a pull request (execs git-merge)') - sp_sh.add_argument('--guess-lookback', dest='guessdays', type=int, default=21, - help=('(use with -H or -M) When guessing base, go back this many days from the patch date ' - '(default: 3 weeks)')) - sp_sh.add_argument('--merge-base', dest='mergebase', type=str, default=None, - help='(use with -H or -M) Force this base when merging') - sp_sh.add_argument('--resolve', dest='shazam_resolve', action='store_true', default=False, - help='(use with -H or -M) Enable conflict resolution if patches fail to apply') - sp_sh.add_argument('--continue', dest='shazam_continue', action='store_true', default=False, - help='Continue after resolving merge conflicts from --resolve') - sp_sh.add_argument('--abort', dest='shazam_abort', action='store_true', default=False, - help='Abort a conflicted shazam and clean up') + sh_g.add_argument( + '-H', + '--make-fetch-head', + dest='makefetchhead', + action='store_true', + default=False, + help='Attempt to treat series as a pull request and fetch it into FETCH_HEAD', + ) + sh_g.add_argument( + '-M', + '--merge', + dest='merge', + action='store_true', + default=False, + help='Attempt to merge series as if it were a pull request (execs git-merge)', + ) + sp_sh.add_argument( + '--guess-lookback', + dest='guessdays', + type=int, + default=21, + help=( + '(use with -H or -M) When guessing base, go back this many days from the patch date ' + '(default: 3 weeks)' + ), + ) + sp_sh.add_argument( + '--merge-base', + dest='mergebase', + type=str, + default=None, + help='(use with -H or -M) Force this base when merging', + ) + sp_sh.add_argument( + '--resolve', + dest='shazam_resolve', + action='store_true', + default=False, + help='(use with -H or -M) Enable conflict resolution if patches fail to apply', + ) + sp_sh.add_argument( + '--continue', + dest='shazam_continue', + action='store_true', + default=False, + help='Continue after resolving merge conflicts from --resolve', + ) + sp_sh.add_argument( + '--abort', + dest='shazam_abort', + action='store_true', + default=False, + help='Abort a conflicted shazam and clean up', + ) sp_sh.set_defaults(func=cmd_shazam) # b4 review - sp_rev = subparsers.add_parser('review', help='Review patch series received on mailing lists') + sp_rev = subparsers.add_parser( + 'review', help='Review patch series received on mailing lists' + ) sp_rev.set_defaults(func=cmd_review) - rev_subparsers = sp_rev.add_subparsers(help='review sub-command help', dest='review_subcmd') + rev_subparsers = sp_rev.add_subparsers( + help='review sub-command help', dest='review_subcmd' + ) # b4 review tui sp_rev_tui = rev_subparsers.add_parser('tui', help='Browse tracked series in a TUI') - sp_rev_tui.add_argument('-i', '--identifier', dest='identifier', default=None, - help='Project identifier (required if not in an enrolled repository)') - sp_rev_tui.add_argument('--email-dry-run', dest='email_dryrun', action='store_true', default=False, - help='Show all email dialogs but print messages to stdout instead of sending') - sp_rev_tui.add_argument('--no-sign', dest='no_sign', action='store_true', default=False, - help='Do not patatt-sign outgoing review emails') - sp_rev_tui.add_argument('--no-mouse', dest='no_mouse', action='store_true', default=False, - help='Disable mouse support in the TUI') + sp_rev_tui.add_argument( + '-i', + '--identifier', + dest='identifier', + default=None, + help='Project identifier (required if not in an enrolled repository)', + ) + sp_rev_tui.add_argument( + '--email-dry-run', + dest='email_dryrun', + action='store_true', + default=False, + help='Show all email dialogs but print messages to stdout instead of sending', + ) + sp_rev_tui.add_argument( + '--no-sign', + dest='no_sign', + action='store_true', + default=False, + help='Do not patatt-sign outgoing review emails', + ) + sp_rev_tui.add_argument( + '--no-mouse', + dest='no_mouse', + action='store_true', + default=False, + help='Disable mouse support in the TUI', + ) # b4 review enroll - sp_rev_enroll = rev_subparsers.add_parser('enroll', help='Enroll a repository for review tracking') - sp_rev_enroll.add_argument('repo_path', nargs='?', default=None, - help='Path to the git repository to enroll (default: current directory)') - sp_rev_enroll.add_argument('-i', '--identifier', dest='identifier', default=None, - help='Project identifier (default: repository directory name)') + sp_rev_enroll = rev_subparsers.add_parser( + 'enroll', help='Enroll a repository for review tracking' + ) + sp_rev_enroll.add_argument( + 'repo_path', + nargs='?', + default=None, + help='Path to the git repository to enroll (default: current directory)', + ) + sp_rev_enroll.add_argument( + '-i', + '--identifier', + dest='identifier', + default=None, + help='Project identifier (default: repository directory name)', + ) # b4 review track sp_rev_track = rev_subparsers.add_parser('track', help='Track a series for review') - sp_rev_track.add_argument('series_id', nargs='?', default=None, - help='Series identifier (message-id, URL, or change-id); or pipe message to stdin') - sp_rev_track.add_argument('-i', '--identifier', dest='identifier', default=None, - help='Project identifier (required if not in an enrolled repository)') - sp_rev_track.add_argument('--rethread', nargs='+', metavar='MSGID', default=None, - help='Rethread multiple unrelated messages into a single series for tracking ' - '(pass - to read message IDs from stdin, one per line)') + sp_rev_track.add_argument( + 'series_id', + nargs='?', + default=None, + help='Series identifier (message-id, URL, or change-id); or pipe message to stdin', + ) + sp_rev_track.add_argument( + '-i', + '--identifier', + dest='identifier', + default=None, + help='Project identifier (required if not in an enrolled repository)', + ) + sp_rev_track.add_argument( + '--rethread', + nargs='+', + metavar='MSGID', + default=None, + help='Rethread multiple unrelated messages into a single series for tracking ' + '(pass - to read message IDs from stdin, one per line)', + ) # b4 review show-info - sp_rev_showinfo = rev_subparsers.add_parser('show-info', - help='Show review branch info in a format suitable for scripting') - sp_rev_showinfo.add_argument('param', metavar='PARAM', nargs='?', + sp_rev_showinfo = rev_subparsers.add_parser( + 'show-info', help='Show review branch info in a format suitable for scripting' + ) + sp_rev_showinfo.add_argument( + 'param', + metavar='PARAM', + nargs='?', default=':_all', - help='[branch:]key — branch and/or key to display (default: all)') - sp_rev_showinfo.add_argument('-l', '--list', dest='list_branches', - action='store_true', default=False, - help='List all review branches with summary info') - sp_rev_showinfo.add_argument('-j', '--json', dest='json_output', - action='store_true', default=False, - help='Output in JSON format') - + help='[branch:]key — branch and/or key to display (default: all)', + ) + sp_rev_showinfo.add_argument( + '-l', + '--list', + dest='list_branches', + action='store_true', + default=False, + help='List all review branches with summary info', + ) + sp_rev_showinfo.add_argument( + '-j', + '--json', + dest='json_output', + action='store_true', + default=False, + help='Output in JSON format', + ) # b4 pr - sp_pr = subparsers.add_parser('pr', help='Fetch a pull request found in a message ID') - sp_pr.add_argument('-g', '--gitdir', default=None, - help='Operate on this git tree instead of current dir') - sp_pr.add_argument('-b', '--branch', default=None, - help='Check out FETCH_HEAD into this branch after fetching') - sp_pr.add_argument('-c', '--check', action='store_true', default=False, - help='Check if pull request has already been applied') - sp_pr.add_argument('-e', '--explode', action='store_true', default=False, - help='Convert a pull request into an mbox full of patches') - sp_pr.add_argument('-o', '--output-mbox', dest='outmbox', default=None, - help='Save exploded messages into this mailbox (default: msgid.mbx)') - sp_pr.add_argument('-f', '--from-addr', dest='mailfrom', default=None, - help='Use this From: in exploded messages (use with -e)') - sp_pr.add_argument('-s', '--send-as-identity', dest='sendidentity', default=None, - help=('Use git-send-email to send exploded series (use with -e);' - 'the identity must match a [sendemail "identity"] config section')) - sp_pr.add_argument('--dry-run', dest='dryrun', action='store_true', default=False, - help='Force a --dry-run on git-send-email invocation (use with -s)') - sp_pr.add_argument('msgid', nargs='?', - help='Message ID to process, or pipe a raw message') + sp_pr = subparsers.add_parser( + 'pr', help='Fetch a pull request found in a message ID' + ) + sp_pr.add_argument( + '-g', + '--gitdir', + default=None, + help='Operate on this git tree instead of current dir', + ) + sp_pr.add_argument( + '-b', + '--branch', + default=None, + help='Check out FETCH_HEAD into this branch after fetching', + ) + sp_pr.add_argument( + '-c', + '--check', + action='store_true', + default=False, + help='Check if pull request has already been applied', + ) + sp_pr.add_argument( + '-e', + '--explode', + action='store_true', + default=False, + help='Convert a pull request into an mbox full of patches', + ) + sp_pr.add_argument( + '-o', + '--output-mbox', + dest='outmbox', + default=None, + help='Save exploded messages into this mailbox (default: msgid.mbx)', + ) + sp_pr.add_argument( + '-f', + '--from-addr', + dest='mailfrom', + default=None, + help='Use this From: in exploded messages (use with -e)', + ) + sp_pr.add_argument( + '-s', + '--send-as-identity', + dest='sendidentity', + default=None, + help=( + 'Use git-send-email to send exploded series (use with -e);' + 'the identity must match a [sendemail "identity"] config section' + ), + ) + sp_pr.add_argument( + '--dry-run', + dest='dryrun', + action='store_true', + default=False, + help='Force a --dry-run on git-send-email invocation (use with -s)', + ) + sp_pr.add_argument( + 'msgid', nargs='?', help='Message ID to process, or pipe a raw message' + ) sp_pr.set_defaults(func=cmd_pr) # b4 ty - sp_ty = subparsers.add_parser('ty', help='Generate thanks email when something gets merged/applied') - sp_ty.add_argument('-g', '--gitdir', default=None, - help='Operate on this git tree instead of current dir') - sp_ty.add_argument('-o', '--outdir', default='.', - help='Write thanks files into this dir (default=.)') - sp_ty.add_argument('-l', '--list', action='store_true', default=False, - help='List pull requests and patch series you have retrieved') - sp_ty.add_argument('-t', '--thank-for', dest='thankfor', default=None, - help='Generate thankyous for specific entries from -l (e.g.: 1,3-5,7-; or "all")') - sp_ty.add_argument('-d', '--discard', default=None, - help='Discard specific messages from -l (e.g.: 1,3-5,7-; or "all")') - sp_ty.add_argument('-a', '--auto', action='store_true', default=False, - help='Use the Auto-Thankanator to figure out what got applied/merged') - sp_ty.add_argument('-b', '--branch', default=None, - help='The branch to check against, instead of current') - sp_ty.add_argument('--since', default='1.week', - help='The --since option to use when auto-matching patches (default=1.week)') - sp_ty.add_argument('-S', '--send-email', action='store_true', dest='sendemail', default=False, - help='Send email instead of writing out .thanks files') - sp_ty.add_argument('--dry-run', action='store_true', dest='dryrun', default=False, - help='Print out emails instead of sending them') - sp_ty.add_argument('--pw-set-state', default=None, - help='Set this patchwork state instead of default (use with -a, -t or -d)') - sp_ty.add_argument('--me-too', action='store_true', dest='metoo', default=False, - help='Send a copy of the thank-you message to yourself as well') + sp_ty = subparsers.add_parser( + 'ty', help='Generate thanks email when something gets merged/applied' + ) + sp_ty.add_argument( + '-g', + '--gitdir', + default=None, + help='Operate on this git tree instead of current dir', + ) + sp_ty.add_argument( + '-o', + '--outdir', + default='.', + help='Write thanks files into this dir (default=.)', + ) + sp_ty.add_argument( + '-l', + '--list', + action='store_true', + default=False, + help='List pull requests and patch series you have retrieved', + ) + sp_ty.add_argument( + '-t', + '--thank-for', + dest='thankfor', + default=None, + help='Generate thankyous for specific entries from -l (e.g.: 1,3-5,7-; or "all")', + ) + sp_ty.add_argument( + '-d', + '--discard', + default=None, + help='Discard specific messages from -l (e.g.: 1,3-5,7-; or "all")', + ) + sp_ty.add_argument( + '-a', + '--auto', + action='store_true', + default=False, + help='Use the Auto-Thankanator to figure out what got applied/merged', + ) + sp_ty.add_argument( + '-b', + '--branch', + default=None, + help='The branch to check against, instead of current', + ) + sp_ty.add_argument( + '--since', + default='1.week', + help='The --since option to use when auto-matching patches (default=1.week)', + ) + sp_ty.add_argument( + '-S', + '--send-email', + action='store_true', + dest='sendemail', + default=False, + help='Send email instead of writing out .thanks files', + ) + sp_ty.add_argument( + '--dry-run', + action='store_true', + dest='dryrun', + default=False, + help='Print out emails instead of sending them', + ) + sp_ty.add_argument( + '--pw-set-state', + default=None, + help='Set this patchwork state instead of default (use with -a, -t or -d)', + ) + sp_ty.add_argument( + '--me-too', + action='store_true', + dest='metoo', + default=False, + help='Send a copy of the thank-you message to yourself as well', + ) sp_ty.set_defaults(func=cmd_ty) # b4 diff - sp_diff = subparsers.add_parser('diff', help='Show a range-diff to previous series revision') - sp_diff.add_argument('msgid', nargs='?', - help='Message ID to process, or pipe a raw message') - sp_diff.add_argument('-g', '--gitdir', default=None, - help='Operate on this git tree instead of current dir') - sp_diff.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False, - help='Do not use local cache') - sp_diff.add_argument('-v', '--compare-versions', dest='wantvers', type=int, default=None, nargs='+', - help='Compare specific versions instead of latest and one before that, e.g. -v 3 5') - sp_diff.add_argument('-n', '--no-diff', dest='nodiff', action='store_true', default=False, - help='Do not generate a diff, just show the command to do it') - sp_diff.add_argument('-o', '--output-diff', dest='outdiff', default=None, - help='Save diff into this file instead of outputting to stdout') - sp_diff.add_argument('-c', '--color', dest='color', action='store_true', default=False, - help='Force color output even when writing to file') - sp_diff.add_argument('-m', '--compare-am-mboxes', dest='ambox', nargs=2, default=None, - help='Compare two mbx files prepared with "b4 am"') - sp_diff.add_argument('--range-diff-opts', default=None, - help='Arguments passed to git range-diff') + sp_diff = subparsers.add_parser( + 'diff', help='Show a range-diff to previous series revision' + ) + sp_diff.add_argument( + 'msgid', nargs='?', help='Message ID to process, or pipe a raw message' + ) + sp_diff.add_argument( + '-g', + '--gitdir', + default=None, + help='Operate on this git tree instead of current dir', + ) + sp_diff.add_argument( + '-C', + '--no-cache', + dest='nocache', + action='store_true', + default=False, + help='Do not use local cache', + ) + sp_diff.add_argument( + '-v', + '--compare-versions', + dest='wantvers', + type=int, + default=None, + nargs='+', + help='Compare specific versions instead of latest and one before that, e.g. -v 3 5', + ) + sp_diff.add_argument( + '-n', + '--no-diff', + dest='nodiff', + action='store_true', + default=False, + help='Do not generate a diff, just show the command to do it', + ) + sp_diff.add_argument( + '-o', + '--output-diff', + dest='outdiff', + default=None, + help='Save diff into this file instead of outputting to stdout', + ) + sp_diff.add_argument( + '-c', + '--color', + dest='color', + action='store_true', + default=False, + help='Force color output even when writing to file', + ) + sp_diff.add_argument( + '-m', + '--compare-am-mboxes', + dest='ambox', + nargs=2, + default=None, + help='Compare two mbx files prepared with "b4 am"', + ) + sp_diff.add_argument( + '--range-diff-opts', default=None, help='Arguments passed to git range-diff' + ) 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.add_argument( + '--show-keys', + dest='showkeys', + action='store_true', + default=False, + help='Show all developer keys found in a thread', + ) sp_kr.set_defaults(func=cmd_kr) # b4 prep - sp_prep = subparsers.add_parser('prep', help='Work on patch series to submit for mailing list review') - sp_prep.add_argument('-c', '--auto-to-cc', action='store_true', default=False, - help='Automatically populate cover letter trailers with To and Cc addresses') - sp_prep.add_argument('--force-revision', metavar='N', type=int, - help='Force revision to be this number instead') - sp_prep.add_argument('--set-prefixes', metavar='PREFIX', nargs='+', - help='Prefixes to include after [PATCH] (e.g.: RFC mydrv)') - sp_prep.add_argument('--add-prefixes', metavar='PREFIX', nargs='+', - help='Additional prefixes to add to those already defined') - sp_prep.add_argument('--set-presubject', metavar='PRESUBJECT', type=str, default=None, - help='Prefix to include before [PATCH] (e.g.: [mylist])') - sp_prep.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False, - help='Do not use local cache') - sp_prep.add_argument('--range-diff-opts', default=None, type=str, - help='Arguments passed to git range-diff when comparing series') + sp_prep = subparsers.add_parser( + 'prep', help='Work on patch series to submit for mailing list review' + ) + sp_prep.add_argument( + '-c', + '--auto-to-cc', + action='store_true', + default=False, + help='Automatically populate cover letter trailers with To and Cc addresses', + ) + sp_prep.add_argument( + '--force-revision', + metavar='N', + type=int, + help='Force revision to be this number instead', + ) + sp_prep.add_argument( + '--set-prefixes', + metavar='PREFIX', + nargs='+', + help='Prefixes to include after [PATCH] (e.g.: RFC mydrv)', + ) + sp_prep.add_argument( + '--add-prefixes', + metavar='PREFIX', + nargs='+', + help='Additional prefixes to add to those already defined', + ) + sp_prep.add_argument( + '--set-presubject', + metavar='PRESUBJECT', + type=str, + default=None, + help='Prefix to include before [PATCH] (e.g.: [mylist])', + ) + sp_prep.add_argument( + '-C', + '--no-cache', + dest='nocache', + action='store_true', + default=False, + help='Do not use local cache', + ) + sp_prep.add_argument( + '--range-diff-opts', + default=None, + type=str, + help='Arguments passed to git range-diff when comparing series', + ) spp_g = sp_prep.add_mutually_exclusive_group() - spp_g.add_argument('-p', '--format-patch', metavar='OUTPUT_DIR', - help='Output prep-tracked commits as patches') - spp_g.add_argument('--edit-cover', action='store_true', default=False, - help='Edit the cover letter in the configured editor') - spp_g.add_argument('--edit-deps', action='store_true', default=False, - help='Edit the series dependencies in the configured editor') - spp_g.add_argument('--check-deps', action='store_true', default=False, - help='Run checks for any defined series dependencies') - spp_g.add_argument('--check', action='store_true', default=False, - help='Run checks on the series') - spp_g.add_argument('--show-revision', action='store_true', default=False, - help='Show current series revision number') - spp_g.add_argument('--compare-to', metavar='vN', - help='Display a range-diff to previously sent revision N') - spp_g.add_argument('--manual-reroll', dest='reroll', default=None, metavar='COVER_MSGID', - help='Mark current revision as sent and reroll (requires cover letter msgid)') - spp_g.add_argument('--show-info', metavar='PARAM', nargs='?', const=':_all', - help='Show series info in a format that can be passed to other commands.') - spp_g.add_argument('--cleanup', metavar='BRANCHNAME', nargs='*', - help='Archive and remove prep-tracked branches and all associated sent/ tags') - - ag_prepn = sp_prep.add_argument_group('Create new branch', 'Create a new branch for working on patch series') - ag_prepn.add_argument('-n', '--new', dest='new_series_name', - help='Create a new branch for working on a patch series') - ag_prepn.add_argument('-f', '--fork-point', dest='fork_point', - help='When creating a new branch, use this fork point instead of HEAD') - ag_prepn.add_argument('-F', '--from-thread', metavar='MSGID', dest='msgid', - help='When creating a new branch, use this thread') - ag_prepe = sp_prep.add_argument_group('Enroll existing branch', 'Enroll existing branch for prep work') - ag_prepe.add_argument('-e', '--enroll', dest='enroll_base', nargs='?', const='@{upstream}', - help='Enroll current branch, using its configured upstream branch as fork base, ' - 'or the passed tag, branch, or commit') + spp_g.add_argument( + '-p', + '--format-patch', + metavar='OUTPUT_DIR', + help='Output prep-tracked commits as patches', + ) + spp_g.add_argument( + '--edit-cover', + action='store_true', + default=False, + help='Edit the cover letter in the configured editor', + ) + spp_g.add_argument( + '--edit-deps', + action='store_true', + default=False, + help='Edit the series dependencies in the configured editor', + ) + spp_g.add_argument( + '--check-deps', + action='store_true', + default=False, + help='Run checks for any defined series dependencies', + ) + spp_g.add_argument( + '--check', action='store_true', default=False, help='Run checks on the series' + ) + spp_g.add_argument( + '--show-revision', + action='store_true', + default=False, + help='Show current series revision number', + ) + spp_g.add_argument( + '--compare-to', + metavar='vN', + help='Display a range-diff to previously sent revision N', + ) + spp_g.add_argument( + '--manual-reroll', + dest='reroll', + default=None, + metavar='COVER_MSGID', + help='Mark current revision as sent and reroll (requires cover letter msgid)', + ) + spp_g.add_argument( + '--show-info', + metavar='PARAM', + nargs='?', + const=':_all', + help='Show series info in a format that can be passed to other commands.', + ) + spp_g.add_argument( + '--cleanup', + metavar='BRANCHNAME', + nargs='*', + help='Archive and remove prep-tracked branches and all associated sent/ tags', + ) + + ag_prepn = sp_prep.add_argument_group( + 'Create new branch', 'Create a new branch for working on patch series' + ) + ag_prepn.add_argument( + '-n', + '--new', + dest='new_series_name', + help='Create a new branch for working on a patch series', + ) + ag_prepn.add_argument( + '-f', + '--fork-point', + dest='fork_point', + help='When creating a new branch, use this fork point instead of HEAD', + ) + ag_prepn.add_argument( + '-F', + '--from-thread', + metavar='MSGID', + dest='msgid', + help='When creating a new branch, use this thread', + ) + ag_prepe = sp_prep.add_argument_group( + 'Enroll existing branch', 'Enroll existing branch for prep work' + ) + ag_prepe.add_argument( + '-e', + '--enroll', + dest='enroll_base', + nargs='?', + const='@{upstream}', + help='Enroll current branch, using its configured upstream branch as fork base, ' + 'or the passed tag, branch, or commit', + ) sp_prep.set_defaults(func=cmd_prep) # b4 trailers - sp_trl = subparsers.add_parser('trailers', help='Operate on trailers received for mailing list reviews') - sp_trl.add_argument('-u', '--update', action='store_true', default=False, - help='Update branch commits with latest received trailers') - sp_trl.add_argument('-S', '--sloppy-trailers', dest='sloppytrailers', action='store_true', default=False, - help='Apply trailers without email address match checking') - sp_trl.add_argument('-F', '--trailers-from', dest='trailers_from', metavar='MSGID', - help='Look for trailers in the thread with this msgid instead of using the series change-id') - sp_trl.add_argument('--since', default='1.month', metavar='GITLOGDATE', - help='The --since option to use with git-log when auto-matching patches (default=1.month)') - sp_trl.add_argument('--since-commit', metavar='COMMITISH', - help='Look for any new trailers for commits starting with this one') + sp_trl = subparsers.add_parser( + 'trailers', help='Operate on trailers received for mailing list reviews' + ) + sp_trl.add_argument( + '-u', + '--update', + action='store_true', + default=False, + help='Update branch commits with latest received trailers', + ) + sp_trl.add_argument( + '-S', + '--sloppy-trailers', + dest='sloppytrailers', + action='store_true', + default=False, + help='Apply trailers without email address match checking', + ) + sp_trl.add_argument( + '-F', + '--trailers-from', + dest='trailers_from', + metavar='MSGID', + help='Look for trailers in the thread with this msgid instead of using the series change-id', + ) + sp_trl.add_argument( + '--since', + default='1.month', + metavar='GITLOGDATE', + help='The --since option to use with git-log when auto-matching patches (default=1.month)', + ) + sp_trl.add_argument( + '--since-commit', + metavar='COMMITISH', + help='Look for any new trailers for commits starting with this one', + ) cmd_retrieval_common_opts(sp_trl) sp_trl.set_defaults(func=cmd_trailers) # b4 send - sp_send = subparsers.add_parser('send', help='Submit your work for review on the mailing lists') + sp_send = subparsers.add_parser( + 'send', help='Submit your work for review on the mailing lists' + ) sp_send_g = sp_send.add_mutually_exclusive_group() - sp_send_g.add_argument('-d', '--dry-run', dest='dryrun', action='store_true', default=False, - help='Do not send, just dump out raw smtp messages to the stdout') - sp_send_g.add_argument('-o', '--output-dir', - help='Do not send, write raw messages to this directory (forces --dry-run)') - sp_send_g.add_argument('--preview-to', nargs='+', metavar='ADDR', - help='Send everything for a pre-review to specified addresses instead of actual recipients') - sp_send_g.add_argument('--reflect', action='store_true', default=False, - help='Send everything to yourself instead of the actual recipients') - - sp_send.add_argument('--no-trailer-to-cc', action='store_true', default=False, - help='Do not add any addresses found in the cover or patch trailers to To: or Cc:') - sp_send.add_argument('--to', nargs='+', metavar='ADDR', help='Addresses to add to the To: list') - sp_send.add_argument('--cc', nargs='+', metavar='ADDR', help='Addresses to add to the Cc: list') - sp_send.add_argument('--not-me-too', action='store_true', default=False, - help='Remove yourself from the To: or Cc: list') - sp_send.add_argument('--resend', metavar='vN', nargs='?', const='latest', - help='Resend a previously sent version of the series') - sp_send.add_argument('--no-sign', action='store_true', default=False, - help='Do not add the cryptographic attestation signature header') - sp_send.add_argument('--force-cover-letter', action='store_true', default=False, - help='Send a cover letter even for single-patch series') - sp_send.add_argument('--use-web-endpoint', dest='send_web', action='store_true', default=False, - help="Force going through the web endpoint") - ag_sendh = sp_send.add_argument_group('Web submission', 'Authenticate with the web submission endpoint') - ag_sendh.add_argument('--web-auth-new', dest='auth_new', action='store_true', default=False, - help='Initiate a new web authentication request') - ag_sendh.add_argument('--web-auth-verify', dest='auth_verify', metavar='VERIFY_TOKEN', - help='Submit the token received via verification email') + sp_send_g.add_argument( + '-d', + '--dry-run', + dest='dryrun', + action='store_true', + default=False, + help='Do not send, just dump out raw smtp messages to the stdout', + ) + sp_send_g.add_argument( + '-o', + '--output-dir', + help='Do not send, write raw messages to this directory (forces --dry-run)', + ) + sp_send_g.add_argument( + '--preview-to', + nargs='+', + metavar='ADDR', + help='Send everything for a pre-review to specified addresses instead of actual recipients', + ) + sp_send_g.add_argument( + '--reflect', + action='store_true', + default=False, + help='Send everything to yourself instead of the actual recipients', + ) + + sp_send.add_argument( + '--no-trailer-to-cc', + action='store_true', + default=False, + help='Do not add any addresses found in the cover or patch trailers to To: or Cc:', + ) + sp_send.add_argument( + '--to', nargs='+', metavar='ADDR', help='Addresses to add to the To: list' + ) + sp_send.add_argument( + '--cc', nargs='+', metavar='ADDR', help='Addresses to add to the Cc: list' + ) + sp_send.add_argument( + '--not-me-too', + action='store_true', + default=False, + help='Remove yourself from the To: or Cc: list', + ) + sp_send.add_argument( + '--resend', + metavar='vN', + nargs='?', + const='latest', + help='Resend a previously sent version of the series', + ) + sp_send.add_argument( + '--no-sign', + action='store_true', + default=False, + help='Do not add the cryptographic attestation signature header', + ) + sp_send.add_argument( + '--force-cover-letter', + action='store_true', + default=False, + help='Send a cover letter even for single-patch series', + ) + sp_send.add_argument( + '--use-web-endpoint', + dest='send_web', + action='store_true', + default=False, + help='Force going through the web endpoint', + ) + ag_sendh = sp_send.add_argument_group( + 'Web submission', 'Authenticate with the web submission endpoint' + ) + ag_sendh.add_argument( + '--web-auth-new', + dest='auth_new', + action='store_true', + default=False, + help='Initiate a new web authentication request', + ) + ag_sendh.add_argument( + '--web-auth-verify', + dest='auth_verify', + metavar='VERIFY_TOKEN', + help='Submit the token received via verification email', + ) sp_send.set_defaults(func=cmd_send) # b4 dig - sp_dig = subparsers.add_parser('dig', help='Dig into the details of a specific commit') - sp_dig.add_argument('-c', '--commitish', dest='commitish', metavar='COMMITISH', - help='Commit-ish object to dig into') - sp_dig.add_argument('-C', '--no-cache', dest='nocache', action='store_true', default=False, - help='Do not use local cache') + sp_dig = subparsers.add_parser( + 'dig', help='Dig into the details of a specific commit' + ) + sp_dig.add_argument( + '-c', + '--commitish', + dest='commitish', + metavar='COMMITISH', + help='Commit-ish object to dig into', + ) + sp_dig.add_argument( + '-C', + '--no-cache', + dest='nocache', + action='store_true', + default=False, + help='Do not use local cache', + ) sp_dig_eg = sp_dig.add_mutually_exclusive_group() - sp_dig_eg.add_argument('-a', '--all-series', action='store_true', default=False, - help='Show all series, not just the latest matching') - sp_dig_eg.add_argument('-m', '--save-mbox', metavar='DEST', default=None, - help='Save matched thread to the specified mbox file') - sp_dig_eg.add_argument('-w', '--who', action='store_true', default=False, - help='Show list of recipients in the original message') + sp_dig_eg.add_argument( + '-a', + '--all-series', + action='store_true', + default=False, + help='Show all series, not just the latest matching', + ) + sp_dig_eg.add_argument( + '-m', + '--save-mbox', + metavar='DEST', + default=None, + help='Save matched thread to the specified mbox file', + ) + sp_dig_eg.add_argument( + '-w', + '--who', + action='store_true', + default=False, + help='Show list of recipients in the original message', + ) sp_dig.set_defaults(func=cmd_dig) # b4 bugs - sp_bugs = subparsers.add_parser('bugs', help='Manage bug reports from mailing list threads') + sp_bugs = subparsers.add_parser( + 'bugs', help='Manage bug reports from mailing list threads' + ) sp_bugs.set_defaults(func=cmd_bugs) - bugs_subparsers = sp_bugs.add_subparsers(help='bugs sub-command help', dest='bugs_subcmd') + bugs_subparsers = sp_bugs.add_subparsers( + help='bugs sub-command help', dest='bugs_subcmd' + ) # b4 bugs tui - sp_bugs_tui = bugs_subparsers.add_parser('tui', help='Browse and triage bugs in a TUI') - sp_bugs_tui.add_argument('--no-mouse', dest='no_mouse', action='store_true', default=False, - help='Disable mouse support in the TUI') - sp_bugs_tui.add_argument('--email-dry-run', dest='email_dryrun', action='store_true', default=False, - help='Show email dialogs but print messages to stdout instead of sending') - sp_bugs_tui.add_argument('--no-sign', dest='no_sign', action='store_true', default=False, - help='Do not patatt-sign outgoing emails') + sp_bugs_tui = bugs_subparsers.add_parser( + 'tui', help='Browse and triage bugs in a TUI' + ) + sp_bugs_tui.add_argument( + '--no-mouse', + dest='no_mouse', + action='store_true', + default=False, + help='Disable mouse support in the TUI', + ) + sp_bugs_tui.add_argument( + '--email-dry-run', + dest='email_dryrun', + action='store_true', + default=False, + help='Show email dialogs but print messages to stdout instead of sending', + ) + sp_bugs_tui.add_argument( + '--no-sign', + dest='no_sign', + action='store_true', + default=False, + help='Do not patatt-sign outgoing emails', + ) # b4 bugs import - sp_bugs_import = bugs_subparsers.add_parser('import', help='Import a lore thread as a new bug') + sp_bugs_import = bugs_subparsers.add_parser( + 'import', help='Import a lore thread as a new bug' + ) sp_bugs_import.add_argument('msgid', help='Message-ID of the thread to import') - sp_bugs_import.add_argument('--no-parent', dest='noparent', action='store_true', default=False, - help='Break thread at the msgid and ignore parent messages') + sp_bugs_import.add_argument( + '--no-parent', + dest='noparent', + action='store_true', + default=False, + help='Break thread at the msgid and ignore parent messages', + ) # b4 bugs delete - sp_bugs_delete = bugs_subparsers.add_parser('delete', help='Permanently delete a bug') + sp_bugs_delete = bugs_subparsers.add_parser( + 'delete', help='Permanently delete a bug' + ) sp_bugs_delete.add_argument('bugid', help='Bug ID to delete') # b4 bugs refresh - sp_bugs_refresh = bugs_subparsers.add_parser('refresh', help='Refresh bugs with new thread messages') - sp_bugs_refresh.add_argument('bugid', nargs='?', default=None, - help='Bug ID to refresh (default: refresh all open bugs)') + sp_bugs_refresh = bugs_subparsers.add_parser( + 'refresh', help='Refresh bugs with new thread messages' + ) + sp_bugs_refresh.add_argument( + 'bugid', + nargs='?', + default=None, + help='Bug ID to refresh (default: refresh all open bugs)', + ) # b4 bugs list sp_bugs_list = bugs_subparsers.add_parser('list', help='List tracked bugs') - sp_bugs_list.add_argument('--status', choices=['open', 'closed'], default=None, - help='Filter by status') + sp_bugs_list.add_argument( + '--status', choices=['open', 'closed'], default=None, help='Filter by status' + ) sp_bugs_list.add_argument('--label', default=None, help='Filter by label') return parser @@ -564,7 +1278,9 @@ if __name__ == '__main__': try: if b4.__VERSION__.find('-dev') > 0: - base = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) + base = os.path.dirname( + 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: diff --git a/src/b4/diff.py b/src/b4/diff.py index 8045243..7392056 100644 --- a/src/b4/diff.py +++ b/src/b4/diff.py @@ -22,7 +22,9 @@ import b4.mbox logger = b4.logger -def diff_same_thread_series(cmdargs: argparse.Namespace) -> Tuple[Optional[b4.LoreSeries], Optional[b4.LoreSeries]]: +def diff_same_thread_series( + cmdargs: argparse.Namespace, +) -> Tuple[Optional[b4.LoreSeries], Optional[b4.LoreSeries]]: msgid = b4.get_msgid(cmdargs) if not msgid: logger.critical('Please pass msgid on the command-line') @@ -45,7 +47,11 @@ def diff_same_thread_series(cmdargs: argparse.Namespace) -> Tuple[Optional[b4.Lo msgs = list() for cachemsg in os.listdir(cachedir): with open(os.path.join(cachedir, cachemsg), 'rb') as fh: - msgs.append(email.parser.BytesParser(policy=b4.emlpolicy, _class=EmailMessage).parse(fh)) + msgs.append( + email.parser.BytesParser( + policy=b4.emlpolicy, _class=EmailMessage + ).parse(fh) + ) else: msgs = b4.get_pi_thread_by_msgid(msgid, nocache=cmdargs.nocache) if not msgs: @@ -107,7 +113,9 @@ def diff_same_thread_series(cmdargs: argparse.Namespace) -> Tuple[Optional[b4.Lo return lmbx.series[lower], lmbx.series[upper] -def diff_mboxes(cmdargs: argparse.Namespace) -> Tuple[Optional[b4.LoreSeries], Optional[b4.LoreSeries]]: +def diff_mboxes( + cmdargs: argparse.Namespace, +) -> Tuple[Optional[b4.LoreSeries], Optional[b4.LoreSeries]]: chunks = list() for mboxfile in cmdargs.ambox: if not os.path.exists(mboxfile): @@ -125,7 +133,9 @@ def diff_mboxes(cmdargs: argparse.Namespace) -> Tuple[Optional[b4.LoreSeries], O 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) + logger.critical( + 'More than one series version in %s, will use latest', mboxfile + ) chunks.append(lmbx.series[max(lmbx.series.keys())]) @@ -145,13 +155,17 @@ def main(cmdargs: argparse.Namespace) -> None: 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) + 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) + logger.critical( + 'Could not create fake-am range for upper series v%s', user.revision + ) sys.exit(1) rd_opts = [] if cmdargs.range_diff_opts: @@ -159,8 +173,12 @@ def main(cmdargs: argparse.Namespace) -> None: sp.whitespace_split = True rd_opts = list(sp) grdcmd = 'git range-diff %s%.12s..%.12s %.12s..%.12s' % ( - " ".join(rd_opts) + " " if rd_opts else "", - lsc, lec, usc, uec) + ' '.join(rd_opts) + ' ' if rd_opts else '', + lsc, + lec, + usc, + uec, + ) if cmdargs.nodiff: logger.info('Success, to compare v%s and v%s:', lser.revision, user.revision) logger.info(' %s', grdcmd) diff --git a/src/b4/dig.py b/src/b4/dig.py index b3d637d..781d509 100644 --- a/src/b4/dig.py +++ b/src/b4/dig.py @@ -60,7 +60,7 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: # Are we inside a git repo? topdir = b4.git_get_toplevel() if not topdir: - logger.error("Not inside a git repository.") + logger.error('Not inside a git repository.') sys.exit(1) # Can we resolve this commit to an object? @@ -73,7 +73,8 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: logger.info('Digging into commit %s', commit) # Make sure it has exactly one parent (not a merge) ecode, out = b4.git_run_command( - topdir, ['show', '--no-patch', '--format=%p', commit], + topdir, + ['show', '--no-patch', '--format=%p', commit], ) if ecode > 0: logger.error('Could not get commit info for %s', commit) @@ -85,7 +86,8 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: # Look at the commit message and find any Link: trailers links: Set[str] = set() ecode, out = b4.git_run_command( - topdir, ['show', '--no-patch', '--format=%B', commit], + topdir, + ['show', '--no-patch', '--format=%B', commit], ) if ecode > 0: logger.error('Could not get commit message for %s', commit) @@ -101,13 +103,16 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: # Find commit's author and subject from git ecode, out = b4.git_run_command( - topdir, ['show', '--no-patch', '--format=%as%x00%ae%x00%an%x00%s', commit], + topdir, + ['show', '--no-patch', '--format=%as%x00%ae%x00%an%x00%s', commit], ) if ecode > 0: logger.error('Could not get commit info for %s', commit) sys.exit(1) cdate, fromeml, fromname, csubj = out.strip().split('\x00', maxsplit=3) - logger.debug('cdate=%s, fromeml=%s, fromname=%s, csubj=%s', cdate, fromeml, fromname, csubj) + logger.debug( + 'cdate=%s, fromeml=%s, fromname=%s, csubj=%s', cdate, fromeml, fromname, csubj + ) # Add 24 hours to the date to account for timezones # First, parse YYYY-MM-DD into datetime cdate_dt = datetime.datetime.strptime(cdate, '%Y-%m-%d') # noqa: DTZ007 @@ -129,7 +134,8 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: algoarg = f'--diff-algorithm={algo}' logger.debug('showargs=%s', showargs + [algoarg]) ecode, bpatch = b4.git_run_command( - topdir, ['show'] + showargs + [algoarg] + [commit], + topdir, + ['show'] + showargs + [algoarg] + [commit], decode=False, ) if ecode > 0: @@ -146,12 +152,16 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: sys.exit(1) patch_id = out.split(maxsplit=1)[0] logger.debug('Patch-id for commit %s is %s', commit, patch_id) - logger.info('Trying to find matching series by patch-id %s (%s)', patch_id, algo) + logger.info( + 'Trying to find matching series by patch-id %s (%s)', patch_id, algo + ) # Limit lookup by date prior to the commit date, to weed out any false-positives from # backports or from erroneously resent series extra_query = f'AND d:..{pidate}' logger.debug('extra_query=%s', extra_query) - msgs = b4.get_msgs_by_patch_id(patch_id, nocache=cmdargs.nocache, extra_query=extra_query) + msgs = b4.get_msgs_by_patch_id( + patch_id, nocache=cmdargs.nocache, extra_query=extra_query + ) if msgs: logger.info('Found matching series by patch-id') for msg in msgs: @@ -179,9 +189,15 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: # can search for that exact string on lore. inbody_from = f'From: {fromname} <{fromeml}>' logger.info('Attempting to match by in-body From: line...') - q = '(nq:"%s" AND s:"%s" AND d:..%s)' % (inbody_from.replace('"', ''), csubj.replace('"', ''), pidate) + q = '(nq:"%s" AND s:"%s" AND d:..%s)' % ( + inbody_from.replace('"', ''), + csubj.replace('"', ''), + pidate, + ) logger.debug('q=%s', q) - msgs = b4.get_pi_search_results(q, nocache=cmdargs.nocache, full_threads=False) + msgs = b4.get_pi_search_results( + q, nocache=cmdargs.nocache, full_threads=False + ) if msgs: for msg in msgs: msgid = b4.LoreMessage.get_clean_msgid(msg) @@ -232,11 +248,16 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: elif lser and lser.subject and lser.fromemail: # We're going to match by first patch/cover letter subject and author. # It's not perfect, but it's the best we can do without a change-id. - fillin_q = '(s:"%s" AND f:"%s")' % (lser.subject.replace('"', ''), lser.fromemail) + fillin_q = '(s:"%s" AND f:"%s")' % ( + lser.subject.replace('"', ''), + lser.fromemail, + ) if fillin_q: fillin_q += f' AND d:..{pidate}' logger.debug('fillin_q=%s', fillin_q) - q_msgs = b4.get_pi_search_results(fillin_q, nocache=cmdargs.nocache, full_threads=True) + q_msgs = b4.get_pi_search_results( + fillin_q, nocache=cmdargs.nocache, full_threads=True + ) if q_msgs: for q_msg in q_msgs: lmbx.add_message(q_msg) @@ -311,7 +332,9 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: allrto = email.utils.getaddresses(best_match.msg.get_all('reply-to', [])) if not allrto: allrto = [(best_match.fromname, best_match.fromemail)] - deduped_to, deduped_cc = b4.LoreMessage.make_reply_addrs(allrto, allto + allcc) + deduped_to, deduped_cc = b4.LoreMessage.make_reply_addrs( + allrto, allto + allcc + ) logger.info('---') logger.info('People originally included in this patch:') logger.info(b4.format_addrs(deduped_to + deduped_cc, header_safe=False)) @@ -345,8 +368,13 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None: # Use the first patch in the series as a fallback lmsg = firstmsg logger.info('%s%s', pref, firstmsg.full_subject) - logger.info('%sDate: %s, From: %s <%s>', ' ' * len(pref), - firstmsg.date.strftime('%Y-%m-%d'), firstmsg.fromname, firstmsg.fromemail) + logger.info( + '%sDate: %s, From: %s <%s>', + ' ' * len(pref), + firstmsg.date.strftime('%Y-%m-%d'), + firstmsg.fromname, + firstmsg.fromemail, + ) logger.info('%s%s', ' ' * len(pref), linkmask % lmsg.msgid) diff --git a/src/b4/ez.py b/src/b4/ez.py index 562f2a9..02589c5 100644 --- a/src/b4/ez.py +++ b/src/b4/ez.py @@ -45,7 +45,8 @@ SENT_TAG_PREFIX = 'sent/' DEFAULT_ENDPOINT = 'https://lkml.kernel.org/_b4_submit' -DEFAULT_COVER_TEMPLATE = """ +DEFAULT_COVER_TEMPLATE = ( + """ ${cover} --- @@ -57,9 +58,12 @@ base-commit: ${base_commit} change-id: ${change_id} ${prerequisites} Best regards, --- """ + ' ' + """ +-- """ + + ' ' + + """ ${signature} """ +) DEFAULT_CHANGELOG_TEMPLATE = """ Changes in v${newrev}: @@ -99,7 +103,7 @@ DEPS_HELP = """ """ # Cache of preflight hashes, used to avoid recalculating them -PFHASH_CACHE: Dict[str, str]= dict() +PFHASH_CACHE: Dict[str, str] = dict() def run_rewrite_hook(stage: str) -> None: @@ -158,7 +162,9 @@ def get_auth_configs() -> Tuple[str, str, str, str, str, str]: config = b4.get_main_config() endpoint = config.get('send-endpoint-web', '') if not isinstance(endpoint, str): - logger.debug('Web submission endpoint (b4.send-endpoint-web) is not defined, or is not a string.') + logger.debug( + 'Web submission endpoint (b4.send-endpoint-web) is not defined, or is not a string.' + ) endpoint = None elif not re.search(r'^https?://', endpoint): logger.debug('Web submission endpoint (b4.send-endpoint-web) is not a web URL.') @@ -168,10 +174,14 @@ def get_auth_configs() -> Tuple[str, str, str, str, str, str]: # Use the default endpoint if we are in the kernel repo topdir = b4.git_get_toplevel() if topdir and os.path.exists(os.path.join(topdir, 'Kconfig')): - logger.debug('No sendemail configs found, will use the default web endpoint') + logger.debug( + 'No sendemail configs found, will use the default web endpoint' + ) endpoint = DEFAULT_ENDPOINT else: - raise RuntimeError('Web submission endpoint (b4.send-endpoint-web) is not defined, or is not valid.') + raise RuntimeError( + 'Web submission endpoint (b4.send-endpoint-web) is not defined, or is not valid.' + ) usercfg = b4.get_user_config() myemail = str(usercfg.get('email', '')) @@ -200,16 +210,22 @@ def auth_new() -> None: gpgargs = ['--export', '--export-options', 'export-minimal', '-a', keydata] ecode, out, _err = b4.gpg_run_command(gpgargs) if ecode > 0: - logger.critical('CRITICAL: unable to get PGP public key for %s:%s', algo, keydata) + logger.critical( + 'CRITICAL: unable to get PGP public key for %s:%s', algo, keydata + ) sys.exit(1) pubkey = out.decode() elif algo == 'ed25519': from nacl.encoding import Base64Encoder from nacl.signing import SigningKey + sk = SigningKey(keydata.encode(), encoder=Base64Encoder) pubkey = base64.b64encode(sk.verify_key.encode()).decode() else: - logger.critical('CRITICAL: algorithm %s not currently supported for web endpoint submission', algo) + logger.critical( + 'CRITICAL: algorithm %s not currently supported for web endpoint submission', + algo, + ) sys.exit(1) logger.info('Will submit a new email authorization request to:') @@ -244,7 +260,9 @@ def auth_new() -> None: rdata = res.json() if rdata.get('result') == 'success': logger.info('Challenge generated and sent to %s', myemail) - logger.info('Once you receive it, run b4 send --web-auth-verify [challenge-string]') + logger.info( + 'Once you receive it, run b4 send --web-auth-verify [challenge-string]' + ) sys.exit(0) except Exception: @@ -312,7 +330,9 @@ def get_base_forkpoint(basebranch: str, mybranch: Optional[str] = None) -> str: if mybranch is None: mybranch = b4.git_get_current_branch() if not mybranch: - raise RuntimeError('Not currently on a branch, please checkout a b4-tracked branch') + raise RuntimeError( + 'Not currently on a branch, please checkout a b4-tracked branch' + ) logger.debug('Finding the fork-point with %s', basebranch) gitargs = ['merge-base', '--fork-point', basebranch] lines = b4.git_get_command_lines(None, gitargs) @@ -320,8 +340,12 @@ def get_base_forkpoint(basebranch: str, mybranch: Optional[str] = None) -> str: gitargs = ['merge-base', mybranch, basebranch] lines = b4.git_get_command_lines(None, gitargs) if not lines: - logger.critical('CRITICAL: Could not find common ancestor with %s', basebranch) - raise RuntimeError('Branches %s and %s have no common ancestors' % (basebranch, mybranch)) + logger.critical( + 'CRITICAL: Could not find common ancestor with %s', basebranch + ) + raise RuntimeError( + 'Branches %s and %s have no common ancestors' % (basebranch, mybranch) + ) forkpoint = lines[0] logger.debug('Fork-point between %s and %s is %s', mybranch, basebranch, forkpoint) @@ -331,7 +355,9 @@ def get_base_forkpoint(basebranch: str, mybranch: Optional[str] = None) -> str: def start_new_series(cmdargs: argparse.Namespace) -> None: usercfg = b4.get_user_config() if 'name' not in usercfg or 'email' not in usercfg: - logger.critical('CRITICAL: Unable to add your Signed-off-by: git returned no user.name or user.email') + logger.critical( + 'CRITICAL: Unable to add your Signed-off-by: git returned no user.name or user.email' + ) sys.exit(1) cover = tracking = patches = thread_msgid = revision = None @@ -373,7 +399,9 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: cover_sections.append(section) cover = '\n---\n'.join(cover_sections).strip() except Exception as ex: - logger.critical('CRITICAL: unable to restore tracking information, ignoring') + logger.critical( + 'CRITICAL: unable to restore tracking information, ignoring' + ) logger.critical(' %s', ex) else: @@ -386,9 +414,11 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: # Escape lines starting with "#" so they don't get lost cover = re.sub(r'^(#.*)$', r'>\1', cover, flags=re.M) - cover = (f'{cmsg.subject}\n\n' - f'EDITME: Imported from f{msgid}\n' - f' Please review before sending.\n\n') + cover + cover = ( + f'{cmsg.subject}\n\n' + f'EDITME: Imported from f{msgid}\n' + f' Please review before sending.\n\n' + ) + cover change_id = lser.change_id if not cmdargs.new_series_name: @@ -429,7 +459,7 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: if is_prep_branch(): logger.debug('Will use current branch as dependency.') _pcover, ptracking = load_cover(strip_comments=True) - depends_on = f"change-id: {ptracking['series']['change-id']}:v{ptracking['series']['revision']}" + depends_on = f'change-id: {ptracking["series"]["change-id"]}:v{ptracking["series"]["revision"]}' cmdargs.fork_point = 'HEAD' if mybranch: @@ -442,7 +472,9 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: gitargs = ['branch', '-v', '--contains', cmdargs.fork_point] lines = b4.git_get_command_lines(None, gitargs) if not lines: - logger.critical('CRITICAL: no branch contains fork-point %s', cmdargs.fork_point) + logger.critical( + 'CRITICAL: no branch contains fork-point %s', cmdargs.fork_point + ) sys.exit(1) for line in lines: chunks = line.split(maxsplit=2) @@ -450,15 +482,24 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: if chunks[0] != '*': continue if chunks[1] == mybranch: - logger.debug('branch %s does contain fork-point %s', mybranch, cmdargs.fork_point) + logger.debug( + 'branch %s does contain fork-point %s', + mybranch, + cmdargs.fork_point, + ) basebranch = mybranch break else: basebranch = mybranch if basebranch is None: - logger.critical('CRITICAL: fork-point %s is not on the current branch.', cmdargs.fork_point) - logger.critical(' Switch to the branch you want to use as base and try again.') + logger.critical( + 'CRITICAL: fork-point %s is not on the current branch.', + cmdargs.fork_point, + ) + logger.critical( + ' Switch to the branch you want to use as base and try again.' + ) sys.exit(1) slug = re.sub(r'\W+', '-', cmdargs.new_series_name).strip('-').lower() @@ -476,7 +517,9 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: basebranch = None _cb = b4.git_get_current_branch() if _cb is None: - logger.critical('CRITICAL: not currently on a branch, unable to enroll with a base') + logger.critical( + 'CRITICAL: not currently on a branch, unable to enroll with a base' + ) sys.exit(1) seriesname = branchname = _cb slug = re.sub(r'\W+', '-', branchname).strip('-').lower() @@ -491,13 +534,19 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: elif out: enroll_base = out.strip() # Is it a branch? - gitargs = ['show-ref', f'refs/heads/{enroll_base}', f'refs/remotes/{enroll_base}'] + gitargs = [ + 'show-ref', + f'refs/heads/{enroll_base}', + f'refs/remotes/{enroll_base}', + ] lines = b4.git_get_command_lines(None, gitargs) if lines: try: forkpoint = get_base_forkpoint(enroll_base, mybranch) except RuntimeError as ex: - logger.critical('CRITICAL: could not use %s as enrollment base:', enroll_base) + logger.critical( + 'CRITICAL: could not use %s as enrollment base:', enroll_base + ) logger.critical(' %s', ex) sys.exit(1) basebranch = enroll_base @@ -512,7 +561,9 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: # check branches where this object lives heads = b4.git_branch_contains(None, forkpoint, checkall=True) if mybranch not in heads: - logger.critical('CRITICAL: object %s does not exist on current branch', enroll_base) + logger.critical( + 'CRITICAL: object %s does not exist on current branch', enroll_base + ) sys.exit(1) if strategy != 'commit': # Remove any branches starting with b4/ @@ -521,12 +572,17 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: if head.startswith('b4/'): heads.remove(head) if len(heads) > 1: - logger.critical('CRITICAL: Multiple branches contain object %s, please pass a branch name as base', - enroll_base) + logger.critical( + 'CRITICAL: Multiple branches contain object %s, please pass a branch name as base', + enroll_base, + ) logger.critical(' %s', ', '.join(heads)) sys.exit(1) if len(heads) < 1: - logger.critical('CRITICAL: No other branch contains %s: cannot use as fork base', enroll_base) + logger.critical( + 'CRITICAL: No other branch contains %s: cannot use as fork base', + enroll_base, + ) sys.exit(1) basebranch = heads.pop() @@ -554,7 +610,9 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: gitargs = ['reset', '--hard', forkpoint] ecode, out = b4.git_run_command(None, gitargs, logstderr=True) if ecode > 0: - logger.critical('CRITICAL: not able to reset current branch to %s', forkpoint) + logger.critical( + 'CRITICAL: not able to reset current branch to %s', forkpoint + ) logger.critical(out) sys.exit(1) @@ -572,36 +630,44 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: # create a default cover letter and store it where the strategy indicates uname = str(usercfg.get('name', '')) uemail = str(usercfg.get('email', '')) - carry = (f'EDITME: cover title for {seriesname}', - '', - '# Describe the purpose of this series. The information you put here', - '# will be used by the project maintainer to make a decision whether', - '# your patches should be reviewed, and in what priority order. Please be', - '# very detailed and link to any relevant discussions or sites that the', - '# maintainer can review to better understand your proposed changes. If you', - '# only have a single patch in your series, the contents of the cover', - '# letter will be appended to the "under-the-cut" portion of the patch.', - '', - '# Lines starting with # will be removed from the cover letter. You can', - '# use them to add notes or reminders to yourself. If you want to use', - '# markdown headers in your cover letter, start the line with ">#".', - '', - '# You can add trailers to the cover letter. Any email addresses found in', - '# these trailers will be added to the addresses specified/generated', - '# during the b4 send stage. You can also run "b4 prep --auto-to-cc" to', - '# auto-populate the To: and Cc: trailers based on the code being', - '# modified.', - '', - f'Signed-off-by: {uname} <{uemail}>', - '', - '', - ) + carry = ( + f'EDITME: cover title for {seriesname}', + '', + '# Describe the purpose of this series. The information you put here', + '# will be used by the project maintainer to make a decision whether', + '# your patches should be reviewed, and in what priority order. Please be', + '# very detailed and link to any relevant discussions or sites that the', + '# maintainer can review to better understand your proposed changes. If you', + '# only have a single patch in your series, the contents of the cover', + '# letter will be appended to the "under-the-cut" portion of the patch.', + '', + '# Lines starting with # will be removed from the cover letter. You can', + '# use them to add notes or reminders to yourself. If you want to use', + '# markdown headers in your cover letter, start the line with ">#".', + '', + '# You can add trailers to the cover letter. Any email addresses found in', + '# these trailers will be added to the addresses specified/generated', + '# during the b4 send stage. You can also run "b4 prep --auto-to-cc" to', + '# auto-populate the To: and Cc: trailers based on the code being', + '# modified.', + '', + f'Signed-off-by: {uname} <{uemail}>', + '', + '', + ) cover = '\n'.join(carry) logger.info('Created the default cover letter, you can edit with --edit-cover.') if not tracking: # We don't need all the entropy of uuid, just some of it - changeid = '%s-%s-%s' % (datetime.date.today().strftime('%Y%m%d'), slug, uuid.uuid4().hex[:12]) # noqa: DTZ011 + changeid = ( + '%s-%s-%s' + % ( + datetime.date.today().strftime('%Y%m%d'), # noqa: DTZ011 + slug, + uuid.uuid4().hex[:12], + ) + ) if revision is None: revision = 1 prefixes = list() @@ -662,16 +728,22 @@ def start_new_series(cmdargs: argparse.Namespace) -> None: logger.critical('Could not apply patches from thread: %s', out) sys.exit(ecode) logger.info('---') - logger.info('NOTE: any follow-up trailers were ignored; apply them with b4 trailers -u') + logger.info( + 'NOTE: any follow-up trailers were ignored; apply them with b4 trailers -u' + ) def make_magic_json(data: Dict[str, Any]) -> str: - mj = (f'{MAGIC_MARKER}\n' - '# This section is used internally by b4 prep for tracking purposes.\n') + mj = ( + f'{MAGIC_MARKER}\n' + '# This section is used internally by b4 prep for tracking purposes.\n' + ) return mj + json.dumps(data, indent=2) -def load_cover(strip_comments: bool = False, usebranch: Optional[str] = None) -> Tuple[str, Dict[str, Any]]: +def load_cover( + strip_comments: bool = False, usebranch: Optional[str] = None +) -> Tuple[str, Dict[str, Any]]: strategy = get_cover_strategy(usebranch) if strategy in {'commit', 'tip-commit'}: cover_commit = find_cover_commit(usebranch=usebranch) @@ -718,7 +790,9 @@ def store_cover(content: str, tracking: Dict[str, Any], new: bool = False) -> No cover_message = content + '\n\n' + make_magic_json(tracking) if new: args = ['commit', '--allow-empty', '-F', '-'] - ecode, out = b4.git_run_command(None, args, stdin=cover_message.encode(), logstderr=True) + ecode, out = b4.git_run_command( + None, args, stdin=cover_message.encode(), logstderr=True + ) if ecode > 0: logger.critical('CRITICAL: Generating cover letter commit failed:') logger.critical(out) @@ -730,7 +804,9 @@ def store_cover(content: str, tracking: Dict[str, Any], new: bool = False) -> No raise RuntimeError('Error saving cover letter (commit not found)') fred = FRCommitMessageEditor() fred.add(commit, cover_message) - frargs = fr.FilteringOptions.parse_args(['--force', '--quiet', '--refs', f'{commit}~1..HEAD']) + frargs = fr.FilteringOptions.parse_args( + ['--force', '--quiet', '--refs', f'{commit}~1..HEAD'] + ) frargs.refs = [f'{commit}~1..HEAD'] frf = fr.RepoFilter(frargs, commit_callback=fred.callback) logger.info('Invoking git-filter-repo to update the cover letter.') @@ -752,13 +828,16 @@ def store_cover(content: str, tracking: Dict[str, Any], new: bool = False) -> No # 'tip-merge': in an empty merge commit at the tip of the branch : TODO # (once/if git upstream properly supports it) + def get_cover_strategy(usebranch: Optional[str] = None) -> str: if usebranch: branch = usebranch else: _cb = b4.git_get_current_branch() if _cb is None: - logger.critical('CRITICAL: not currently on a branch, unable to determine cover strategy') + logger.critical( + 'CRITICAL: not currently on a branch, unable to determine cover strategy' + ) sys.exit(1) branch = _cb # Check local branch config for the strategy @@ -778,7 +857,9 @@ def get_cover_strategy(usebranch: Optional[str] = None) -> str: def is_prep_branch(mustbe: bool = False, usebranch: Optional[str] = None) -> bool: - mustmsg = 'CRITICAL: This is not a prep-managed branch or it was created by someone else.' + mustmsg = ( + 'CRITICAL: This is not a prep-managed branch or it was created by someone else.' + ) mybranch: Optional[str] = None if usebranch: mybranch = usebranch @@ -816,13 +897,19 @@ def is_prep_branch(mustbe: bool = False, usebranch: Optional[str] = None) -> boo def find_cover_commit(usebranch: Optional[str] = None) -> Optional[str]: # Walk back commits until we find the cover letter # Our covers always contain the MAGIC_MARKER line - logger.debug('Looking for the cover letter commit with magic marker "%s"', MAGIC_MARKER) + logger.debug( + 'Looking for the cover letter commit with magic marker "%s"', MAGIC_MARKER + ) if not usebranch: usebranch = b4.git_get_current_branch() if usebranch is None: - logger.critical("The current repository is not tracking a branch. To use b4, please checkout a branch.") - logger.critical("Maybe a rebase is running?") - raise RuntimeError("Not currently on a branch, please checkout a b4-tracked branch") + logger.critical( + 'The current repository is not tracking a branch. To use b4, please checkout a branch.' + ) + logger.critical('Maybe a rebase is running?') + raise RuntimeError( + 'Not currently on a branch, please checkout a b4-tracked branch' + ) # Restrict to committer being the current person, in case an errant cover letter # got added into the shared tree, as in: @@ -830,8 +917,18 @@ def find_cover_commit(usebranch: Optional[str] = None) -> Optional[str]: # TODO: make it possible to ignore it, to make it possible to work on deliberately shared trees? usercfg = b4.get_user_config() limit_committer = usercfg['email'] - gitargs = ['log', '--grep', MAGIC_MARKER, '-F', '--pretty=oneline', '--max-count=1', '--since=1.year', - '--no-mailmap', f'--committer={limit_committer}', usebranch] + gitargs = [ + 'log', + '--grep', + MAGIC_MARKER, + '-F', + '--pretty=oneline', + '--max-count=1', + '--since=1.year', + '--no-mailmap', + f'--committer={limit_committer}', + usebranch, + ] lines = b4.git_get_command_lines(None, gitargs) if not lines: return None @@ -934,19 +1031,41 @@ def check_deps(cmdargs: argparse.Namespace) -> None: if matches: wantser = int(matches.groups()[0]) if wantser not in lmbx.series: - logger.debug('FAIL: No matching series %s for change-id %s', wantser, change_id) - res[prereq] = (False, f'No version {wantser} found for change-id {change_id}') + logger.debug( + 'FAIL: No matching series %s for change-id %s', + wantser, + change_id, + ) + res[prereq] = ( + False, + f'No version {wantser} found for change-id {change_id}', + ) continue # Is it the latest version? maxser = max(lmbx.series.keys()) if wantser < maxser: - logger.debug('Fail: Newer version v%s available for change-id %s', maxser, change_id) - res[prereq] = (False, f'v{maxser} available for change-id {change_id} (you have: v{wantser})') + logger.debug( + 'Fail: Newer version v%s available for change-id %s', + maxser, + change_id, + ) + res[prereq] = ( + False, + f'v{maxser} available for change-id {change_id} (you have: v{wantser})', + ) continue - logger.debug('Pass: change-id %s found and is the latest posted series', change_id) - res[prereq] = (True, f'Change-id {change_id} found and is the latest available version') + logger.debug( + 'Pass: change-id %s found and is the latest posted series', + change_id, + ) + res[prereq] = ( + True, + f'Change-id {change_id} found and is the latest available version', + ) lser = lmbx.get_series(wantser, codereview_trailers=False) - assert lser is not None # should never happen if we found the series + assert ( + lser is not None + ) # should never happen if we found the series for lmsg in lser.patches[1:]: if not lmsg: # Should also never happen, but just in case @@ -955,7 +1074,10 @@ def check_deps(cmdargs: argparse.Namespace) -> None: known_patches[lmsg.git_patch_id] = lmsg else: maxser = max(lmbx.series.keys()) - res[prereq] = (False, f'change-id should include the revision, e.g.: {change_id}:v{maxser}') + res[prereq] = ( + False, + f'change-id should include the revision, e.g.: {change_id}:v{maxser}', + ) continue elif parts[0] == 'patch-id': @@ -989,14 +1111,20 @@ def check_deps(cmdargs: argparse.Namespace) -> None: # Always do no-parent for these s_msgs = b4.get_strict_thread(q_msgs, msgid, noparent=True) if not s_msgs: - res[prereq] = (False, 'No matching message-id found on the server after strict thread check') + res[prereq] = ( + False, + 'No matching message-id found on the server after strict thread check', + ) continue lmbx = b4.LoreMailbox() for s_msg in s_msgs: lmbx.add_message(s_msg) if len(lmbx.series) > 1: logger.debug('FAIL: msgid=%s is a thread with multiple series', msgid) - res[prereq] = (False, f'Message-id <{msgid}> has multiple posted series') + res[prereq] = ( + False, + f'Message-id <{msgid}> has multiple posted series', + ) continue maxser = max(lmbx.series.keys()) @@ -1020,11 +1148,16 @@ def check_deps(cmdargs: argparse.Namespace) -> None: allgood = all([x[0] for x in res.values()]) if not base_commit: logger.debug('FAIL: base-commit not specified') - res['base-commit: MISSING'] = (False, 'Series with dependencies require a base-commit') + res['base-commit: MISSING'] = ( + False, + 'Series with dependencies require a base-commit', + ) elif allgood: logger.info('Testing if all patches can be applied to %s', base_commit) - _, _, _, mypatches = get_prep_branch_as_patches(thread=False, movefrom=False, addtracking=False) - if get_cover_strategy() == "commit": + _, _, _, mypatches = get_prep_branch_as_patches( + thread=False, movefrom=False, addtracking=False + ) + if get_cover_strategy() == 'commit': # If the cover letter is stored as a commit, skip it to avoid empty patches prereq_patches += [x[1] for x in mypatches[1:]] else: @@ -1038,15 +1171,23 @@ def check_deps(cmdargs: argparse.Namespace) -> None: b4.save_git_am_mbox(prereq_patches, ifh) ambytes = ifh.getvalue() try: - b4.git_fetch_am_into_repo(topdir, ambytes, at_base=base_commit, check_only=True) + b4.git_fetch_am_into_repo( + topdir, ambytes, at_base=base_commit, check_only=True + ) logger.debug('PASS: Prereqs cleanly apply to %s', base_commit) res[f'base-commit: {base_commit}'] = (True, 'All patches cleanly apply') except RuntimeError: logger.debug('FAIL: Could not cleanly apply patches to %s', base_commit) - res[f'base-commit: {base_commit}'] = (False, 'Could not cleanly apply patches') + res[f'base-commit: {base_commit}'] = ( + False, + 'Could not cleanly apply patches', + ) else: logger.debug('FAIL: %s does not exist in current tree', base_commit) - res[f'base-commit: {base_commit}'] = (False, 'Base commit not found in the current tree') + res[f'base-commit: {base_commit}'] = ( + False, + 'Base commit not found in the current tree', + ) else: logger.info('Not checking applicability of the series due to other errors') @@ -1102,7 +1243,9 @@ def get_series_start(usebranch: Optional[str] = None) -> Optional[str]: def update_trailers(cmdargs: argparse.Namespace) -> None: if not b4.can_network and not cmdargs.localmbox: - logger.critical('CRITICAL: To work in offline mode you have to pass a local mailbox.') + logger.critical( + 'CRITICAL: To work in offline mode you have to pass a local mailbox.' + ) sys.exit(1) usercfg = b4.get_user_config() @@ -1142,25 +1285,38 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: if since_commit: start = f'{since_commit}~1' else: - logger.critical('CRITICAL: Could not resolve %s to a git commit', cmdargs.since_commit) + logger.critical( + 'CRITICAL: Could not resolve %s to a git commit', cmdargs.since_commit + ) sys.exit(1) else: # Find the most recent commit where we're not the committer - gitargs = ['log', '--perl-regexp', '--no-mailmap', - f'--committer=^(?!.*<{limit_committer}>)', '--max-count=1', - '--format=%H', '--since', cmdargs.since] + gitargs = [ + 'log', + '--perl-regexp', + '--no-mailmap', + f'--committer=^(?!.*<{limit_committer}>)', + '--max-count=1', + '--format=%H', + '--since', + cmdargs.since, + ] lines = b4.git_get_command_lines(None, gitargs) if not lines: - logger.critical('CRITICAL: could not find any commits, try changing --since') + logger.critical( + 'CRITICAL: could not find any commits, try changing --since' + ) sys.exit(1) # Iterate through the commits we will consider and do some sanity checking first_considered = lines[0] logger.debug('First commit to consider: %s', first_considered) # Make sure this commit isn't HEAD if first_considered == end: - logger.critical('CRITICAL: the tip commit was not committed by you, refusing to continue') + logger.critical( + 'CRITICAL: the tip commit was not committed by you, refusing to continue' + ) sys.exit(1) start = first_considered @@ -1171,7 +1327,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: lines = b4.git_get_command_lines(None, gitargs) if not lines: # Should never happen? - logger.critical('CRITICAL: could not find any commits between %s and HEAD.', start) + logger.critical( + 'CRITICAL: could not find any commits between %s and HEAD.', start + ) sys.exit(1) first_to_update = end for line in lines: @@ -1180,7 +1338,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: # If we have more than 3 parts, that means we found a commit with multiple parents commit, committer_email, parents = cparts[0], cparts[1], cparts[2:] if len(parents) != 1: - logger.debug('Commit %s has non-single parent, stopping: %s', commit, parents) + logger.debug( + 'Commit %s has non-single parent, stopping: %s', commit, parents + ) break if committer_email != limit_committer and not in_prep_branch: logger.debug('Commit %s is not by %s, stopping', commit, committer_email) @@ -1199,8 +1359,13 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: logger.debug('End of the range: %s', end) try: - patches = b4.git_range_to_patches(None, start, end, ignore_commits=ignore_commits, - limit_committer=limit_committer) + patches = b4.git_range_to_patches( + None, + start, + end, + ignore_commits=ignore_commits, + limit_committer=limit_committer, + ) if cover: cmsg = EmailMessage() cmsg['Subject'] = f'[PATCH 0/{len(patches)}] cover' @@ -1224,7 +1389,10 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: by_patchid: Dict[str, str] = dict() for lmsg in bbox.series[1].patches: if lmsg is None or lmsg.git_patch_id is None: - logger.debug('Skipping None or empty patch-id in %s', lmsg.subject if lmsg else 'unknown message') + logger.debug( + 'Skipping None or empty patch-id in %s', + lmsg.subject if lmsg else 'unknown message', + ) continue by_patchid[lmsg.git_patch_id] = lmsg.msgid commit_map[lmsg.msgid] = lmsg @@ -1253,7 +1421,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: patchid_map = b4.map_codereview_trailers(list_msgs) for patchid, llmsgs in patchid_map.items(): if patchid not in by_patchid: - logger.debug('Skipping patch-id %s: not found in the current series', patchid) + logger.debug( + 'Skipping patch-id %s: not found in the current series', patchid + ) logger.debug('Ignoring follow-ups: %s', [x.subject for x in llmsgs]) continue for llmsg in llmsgs: @@ -1262,7 +1432,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: mismatches.add((ltr.name, ltr.value, llmsg.fromname, llmsg.fromemail)) commit = by_patchid[patchid] lmsg = commit_map[commit] - logger.debug('Adding %s to %s', [x.as_string() for x in ltrailers], lmsg.msgid) + logger.debug( + 'Adding %s to %s', [x.as_string() for x in ltrailers], lmsg.msgid + ) lmsg.followup_trailers += ltrailers if msgid or tracking: @@ -1271,7 +1443,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: else: codereview_trailers = True - lser = bbox.get_series(sloppytrailers=cmdargs.sloppytrailers, codereview_trailers=codereview_trailers) + lser = bbox.get_series( + sloppytrailers=cmdargs.sloppytrailers, codereview_trailers=codereview_trailers + ) if lser is None: logger.critical('CRITICAL: Unable to find series for %s', msgid) sys.exit(1) @@ -1303,7 +1477,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: continue seen_froms.add(rendered) if fltr.lmsg is not None: - source = midmask % urllib.parse.quote_plus(fltr.lmsg.msgid, safe='@') + source = midmask % urllib.parse.quote_plus( + fltr.lmsg.msgid, safe='@' + ) logger.info(' + %s', rendered) logger.info(' via: %s', source) else: @@ -1339,7 +1515,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: logger.critical('---') if not cmdargs.no_interactive: - resp = input('Rewrite %d commit(s) to add these trailers? [y/N] ' % len(commits)) + resp = input( + 'Rewrite %d commit(s) to add these trailers? [y/N] ' % len(commits) + ) if resp.lower() not in {'y', 'yes'}: logger.info('Exiting without changes.') sys.exit(130) @@ -1356,7 +1534,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: logger.debug('commit=%s, message=%s', commit, clmsg.message) fred.add(commit, clmsg.message) logger.info('---') - args = fr.FilteringOptions.parse_args(['--force', '--quiet', '--refs', f'{start}..']) + args = fr.FilteringOptions.parse_args( + ['--force', '--quiet', '--refs', f'{start}..'] + ) args.refs = [f'{start}..'] frf = fr.RepoFilter(args, commit_callback=fred.callback) logger.info('Invoking git-filter-repo to update trailers.') @@ -1364,7 +1544,9 @@ def update_trailers(cmdargs: argparse.Namespace) -> None: logger.info('Trailers updated.') -def get_addresses_from_cmd(cmdargs: List[str], msgbytes: bytes) -> List[Tuple[str, str]]: +def get_addresses_from_cmd( + cmdargs: List[str], msgbytes: bytes +) -> List[Tuple[str, str]]: if not cmdargs: return list() # Run this command from git toplevel @@ -1380,7 +1562,9 @@ def get_addresses_from_cmd(cmdargs: List[str], msgbytes: bytes) -> List[Tuple[st return email.utils.getaddresses(addrs.split('\n')) -def get_series_range(start_commit: Optional[str] = None, usebranch: Optional[str] = None) -> Tuple[str, str, str]: +def get_series_range( + start_commit: Optional[str] = None, usebranch: Optional[str] = None +) -> Tuple[str, str, str]: mybranch: Optional[str] = None if usebranch: mybranch = usebranch @@ -1404,14 +1588,17 @@ def get_series_range(start_commit: Optional[str] = None, usebranch: Optional[str elif mybranch: end_commit = b4.git_revparse_obj(mybranch) else: - logger.critical('CRITICAL: Not currently on a branch, unable to determine end commit') + logger.critical( + 'CRITICAL: Not currently on a branch, unable to determine end commit' + ) sys.exit(1) return base_commit, start_commit, end_commit -def get_series_details(start_commit: Optional[str] = None, usebranch: Optional[str] = None - ) -> Tuple[str, str, str, List[str], str, str]: +def get_series_details( + start_commit: Optional[str] = None, usebranch: Optional[str] = None +) -> Tuple[str, str, str, List[str], str, str]: base_commit, start_commit, end_commit = get_series_range(start_commit, usebranch) gitargs = ['shortlog', f'{start_commit}..{end_commit}'] _, shortlog = b4.git_run_command(None, gitargs) @@ -1420,7 +1607,14 @@ def get_series_details(start_commit: Optional[str] = None, usebranch: Optional[s gitargs = ['log', '--oneline', f'{start_commit}..{end_commit}'] _, _olout = b4.git_run_command(None, gitargs) oneline = _olout.rstrip().splitlines() - return base_commit, start_commit, end_commit, oneline, shortlog.rstrip(), diffstat.rstrip() + return ( + base_commit, + start_commit, + end_commit, + oneline, + shortlog.rstrip(), + diffstat.rstrip(), + ) def get_base_changeid_from_tag(tagname: str) -> Tuple[str, str, str]: @@ -1466,7 +1660,9 @@ def make_msgid_tpt(change_id: str, revision: str, domain: Optional[str] = None) return msgid_tpt -def get_cover_dests(cbody: str) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]], str]: +def get_cover_dests( + cbody: str, +) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]], str]: htrs, cmsg, mtrs, basement, sig = b4.LoreMessage.get_body_parts(cbody) tos = list() ccs = list() @@ -1481,8 +1677,15 @@ def get_cover_dests(cbody: str) -> Tuple[List[Tuple[str, str]], List[Tuple[str, return tos, ccs, cbody -def add_cover(csubject: b4.LoreSubject, msgid_tpt: str, patches: List[Tuple[str, EmailMessage]], - cbody: str, datets: int, thread: bool = True, presubject: Optional[str] = None) -> None: +def add_cover( + csubject: b4.LoreSubject, + msgid_tpt: str, + patches: List[Tuple[str, EmailMessage]], + cbody: str, + datets: int, + thread: bool = True, + presubject: Optional[str] = None, +) -> None: fp = patches[0][1] cmsg = EmailMessage() cmsg.add_header('From', fp['From']) @@ -1491,8 +1694,12 @@ def add_cover(csubject: b4.LoreSubject, msgid_tpt: str, patches: List[Tuple[str, csubject.expected = fpls.expected csubject.counter = 0 csubject.revision = fpls.revision - cmsg.add_header('Subject', csubject.get_rebuilt_subject(eprefixes=fpls.get_extra_prefixes(), - presubject=presubject)) + cmsg.add_header( + 'Subject', + csubject.get_rebuilt_subject( + eprefixes=fpls.get_extra_prefixes(), presubject=presubject + ), + ) cmsg.add_header('Date', email.utils.formatdate(datets, localtime=True)) cmsg.add_header('Message-Id', msgid_tpt % str(0)) @@ -1507,8 +1714,12 @@ def add_cover(csubject: b4.LoreSubject, msgid_tpt: str, patches: List[Tuple[str, def mixin_cover(cbody: str, patches: List[Tuple[str, EmailMessage]]) -> None: msg = patches[0][1] pbody, _pcharset = b4.LoreMessage.get_payload(msg) - pheaders, pmessage, ptrailers, pbasement, _psignature = b4.LoreMessage.get_body_parts(pbody) - _cheaders, cmessage, ctrailers, cbasement, csignature = b4.LoreMessage.get_body_parts(cbody) + pheaders, pmessage, ptrailers, pbasement, _psignature = ( + b4.LoreMessage.get_body_parts(pbody) + ) + _cheaders, cmessage, ctrailers, cbasement, csignature = ( + b4.LoreMessage.get_body_parts(cbody) + ) nbparts = list() nmessage = cmessage.rstrip('\r\n') + '\n' @@ -1544,7 +1755,9 @@ def mixin_cover(cbody: str, patches: List[Tuple[str, EmailMessage]]) -> None: newbasement = '---\n'.join(nbparts) - pbody = b4.LoreMessage.rebuild_message(pheaders, pmessage, ptrailers, newbasement, csignature) + pbody = b4.LoreMessage.rebuild_message( + pheaders, pmessage, ptrailers, newbasement, csignature + ) msg.set_payload(pbody, charset='utf-8') # Check if the new body now has 8bit content and fix CTR if msg.get('Content-Transfer-Encoding') != '8bit' and not pbody.isascii(): @@ -1572,10 +1785,17 @@ def rethread(patches: List[Tuple[str, EmailMessage]]) -> None: msg.add_header('In-Reply-To', refto) -def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtracking: bool = True, - prefixes: Optional[List[str]] = None, usebranch: Optional[str] = None, - expandprereqs: bool = True, force_cover: bool = False, - ) -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]], str, List[Tuple[str, EmailMessage]]]: +def get_prep_branch_as_patches( + movefrom: bool = True, + thread: bool = True, + addtracking: bool = True, + prefixes: Optional[List[str]] = None, + usebranch: Optional[str] = None, + expandprereqs: bool = True, + force_cover: bool = False, +) -> Tuple[ + List[Tuple[str, str]], List[Tuple[str, str]], str, List[Tuple[str, EmailMessage]] +]: cover, tracking = load_cover(strip_comments=True, usebranch=usebranch) if prefixes is None: @@ -1604,17 +1824,22 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr presubject = tracking['series'].get('presubject', list()) - patches = b4.git_range_to_patches(None, start_commit, end_commit, - revision=revision, - prefixes=prefixes, - msgid_tpt=msgid_tpt, - seriests=seriests, - mailfrom=mailfrom, - ignore_commits=ignore_commits, - presubject=presubject) - - base_commit, _, _, _, shortlog, diffstat = get_series_details(start_commit=start_commit, - usebranch=usebranch) + patches = b4.git_range_to_patches( + None, + start_commit, + end_commit, + revision=revision, + prefixes=prefixes, + msgid_tpt=msgid_tpt, + seriests=seriests, + mailfrom=mailfrom, + ignore_commits=ignore_commits, + presubject=presubject, + ) + + base_commit, _, _, _, shortlog, diffstat = get_series_details( + start_commit=start_commit, usebranch=usebranch + ) config = b4.get_main_config() cover_template = DEFAULT_COVER_TEMPLATE @@ -1623,12 +1848,17 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr try: ctf = config['prep-cover-template'] if not isinstance(ctf, str): - logger.critical('ERROR: prep-cover-template must be a string, got %s', type(ctf).__name__) + logger.critical( + 'ERROR: prep-cover-template must be a string, got %s', + type(ctf).__name__, + ) sys.exit(1) cover_template = b4.read_template(ctf) except FileNotFoundError: - logger.critical('ERROR: prep-cover-template says to use %s, but it does not exist', - config['prep-cover-template']) + logger.critical( + 'ERROR: prep-cover-template says to use %s, but it does not exist', + config['prep-cover-template'], + ) sys.exit(2) prereqs = tracking['series'].get('prerequisites', list()) prerequisites = '' @@ -1642,7 +1872,9 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr if prereq.startswith('base-commit:'): base_commit = b4.git_revparse_obj(chunks[1]) if not base_commit: - logger.warning('WARNING: unable to resolve prerequisite-base-commit %s', chunks[1]) + logger.warning( + 'WARNING: unable to resolve prerequisite-base-commit %s', chunks[1] + ) base_commit = chunks[1] else: logger.debug('Overriding base-commit with: %s', base_commit) @@ -1677,12 +1909,15 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr continue logger.debug('Checking if we have a sent version') try: - _, _, ppatches = get_sent_tag_as_patches(tagname, revision=revision, - presubject=presubject) + _, _, ppatches = get_sent_tag_as_patches( + tagname, revision=revision, presubject=presubject + ) for _psha, ppatch in ppatches: spatches.append(ppatch) except RuntimeError: - logger.debug('Nothing matched tagname=%s, checking remotely', tagname) + logger.debug( + 'Nothing matched tagname=%s, checking remotely', tagname + ) lmbx = b4.get_series_by_change_id(pcid) if not lmbx: logger.info('Nothing known about change-id: %s', pcid) @@ -1725,27 +1960,39 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr rd_tptvals = { 'oldrev': oldrev, } - range_diff = Template(rangediff_template.lstrip()).safe_substitute(rd_tptvals) + range_diff = Template(rangediff_template.lstrip()).safe_substitute( + rd_tptvals + ) _rdcmp = range_diff_compare(oldrev, execvp=False) if _rdcmp: range_diff += _rdcmp tptvals['range_diff'] = range_diff else: - tptvals['range_diff'] = "" + tptvals['range_diff'] = '' cover_letter = Template(cover_template.lstrip()).safe_substitute(tptvals) # Store tracking info in the header in a safe format, which should allow us to # fully restore our work from the already sent series. ztracking = gzip.compress(bytes(json.dumps(tracking), 'utf-8')) b4tracking = base64.b64encode(ztracking).decode() # A little trick for pretty wrapping - wrapped = textwrap.wrap('X-B4-Tracking: v=1; b=' + b4tracking, subsequent_indent=' ', width=75) + wrapped = textwrap.wrap( + 'X-B4-Tracking: v=1; b=' + b4tracking, subsequent_indent=' ', width=75 + ) thdata = ''.join(wrapped).replace('X-B4-Tracking: ', '') alltos, allccs, cbody = get_cover_dests(cover_letter) if len(patches) == 1 and not force_cover: mixin_cover(cbody, patches) else: - add_cover(csubject, msgid_tpt, patches, cbody, seriests, thread=thread, presubject=presubject) + add_cover( + csubject, + msgid_tpt, + patches, + cbody, + seriests, + thread=thread, + presubject=presubject, + ) if addtracking: patches[0][1].add_header('X-B4-Tracking', thdata) @@ -1774,8 +2021,14 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr return alltos, allccs, tag_msg, patches -def get_sent_tag_as_patches(tagname: str, revision: int, presubject: Optional[str] = None, force_cover: bool = False) \ - -> Tuple[List[Tuple[str, str]], List[Tuple[str, str]], List[Tuple[str, EmailMessage]]]: +def get_sent_tag_as_patches( + tagname: str, + revision: int, + presubject: Optional[str] = None, + force_cover: bool = False, +) -> Tuple[ + List[Tuple[str, str]], List[Tuple[str, str]], List[Tuple[str, EmailMessage]] +]: cover, base_commit, change_id = get_base_changeid_from_tag(tagname) csubject, cbody = get_cover_subject_body(cover) @@ -1785,13 +2038,17 @@ def get_sent_tag_as_patches(tagname: str, revision: int, presubject: Optional[st seriests = int(time.time()) mailfrom = b4.get_mailfrom() - patches = b4.git_range_to_patches(None, base_commit, tagname, - revision=revision, - prefixes=prefixes, - msgid_tpt=msgid_tpt, - seriests=seriests, - mailfrom=mailfrom, - presubject=presubject) + patches = b4.git_range_to_patches( + None, + base_commit, + tagname, + revision=revision, + prefixes=prefixes, + msgid_tpt=msgid_tpt, + seriests=seriests, + mailfrom=mailfrom, + presubject=presubject, + ) alltos, allccs, cbody = get_cover_dests(cbody) if len(patches) == 1 and not force_cover: @@ -1804,7 +2061,9 @@ def get_sent_tag_as_patches(tagname: str, revision: int, presubject: Optional[st def format_patch(output_dir: str) -> None: try: - _, _, _, patches = get_prep_branch_as_patches(thread=False, movefrom=False, addtracking=False) + _, _, _, patches = get_prep_branch_as_patches( + thread=False, movefrom=False, addtracking=False + ) except RuntimeError as ex: logger.critical('CRITICAL: Failed to convert range to patches: %s', ex) sys.exit(1) @@ -1839,8 +2098,10 @@ def get_check_cmds() -> Tuple[List[str], List[str]]: if topdir: checkpatch = os.path.join(topdir, 'scripts', 'checkpatch.pl') if os.access(checkpatch, os.X_OK): - spell = "--codespell" if can_codespell else "" - ppcmds = [f'{checkpatch} -q --terse --no-summary --mailback --showfile {spell}'] + spell = '--codespell' if can_codespell else '' + ppcmds = [ + f'{checkpatch} -q --terse --no-summary --mailback --showfile {spell}' + ] # TODO: support for a whole-series check command, (pytest, etc) return ppcmds, scmds @@ -1879,7 +2140,9 @@ def check(cmdargs: argparse.Namespace) -> None: continue report = list() for ppcmdargs in local_check_cmds: - ckrep = b4.LoreMessage.run_local_check(ppcmdargs, commit, msg, nocache=cmdargs.nocache) + ckrep = b4.LoreMessage.run_local_check( + ppcmdargs, commit, msg, nocache=cmdargs.nocache + ) if ckrep: report.extend(ckrep) @@ -1902,7 +2165,12 @@ def check(cmdargs: argparse.Namespace) -> None: summary[flag] += 1 logger.info(' %s %s', b4.CI_FLAGS_FANCY[flag], status) logger.info('---') - logger.info('Success: %s, Warning: %s, Error: %s', summary['success'], summary['warning'], summary['fail']) + logger.info( + 'Success: %s, Warning: %s, Error: %s', + summary['success'], + summary['warning'], + summary['fail'], + ) store_preflight_check('check') @@ -1933,7 +2201,9 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: revstr = cmdargs.resend # Start with full change-id based tag name - tagname, revision = get_sent_tagname(tracking['series']['change-id'], SENT_TAG_PREFIX, revstr) + tagname, revision = get_sent_tagname( + tracking['series']['change-id'], SENT_TAG_PREFIX, revstr + ) if revision is None: logger.critical('Could not figure out revision from %s', revstr) @@ -1949,9 +2219,12 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: presubject = tracking['series'].get('presubject', None) try: - todests, ccdests, patches = get_sent_tag_as_patches(tagname, revision=revision, - presubject=presubject, - force_cover=cmdargs.force_cover_letter) + todests, ccdests, patches = get_sent_tag_as_patches( + tagname, + revision=revision, + presubject=presubject, + force_cover=cmdargs.force_cover_letter, + ) except RuntimeError as ex: logger.critical('CRITICAL: Failed to convert tag to patches: %s', ex) sys.exit(1) @@ -1971,8 +2244,9 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: prefixes = None try: - todests, ccdests, tag_msg, patches = get_prep_branch_as_patches(prefixes=prefixes, - force_cover=cmdargs.force_cover_letter) + todests, ccdests, tag_msg, patches = get_prep_branch_as_patches( + prefixes=prefixes, force_cover=cmdargs.force_cover_letter + ) except RuntimeError as ex: logger.critical('CRITICAL: Failed to convert range to patches: %s', ex) sys.exit(1) @@ -2084,7 +2358,9 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: # Use the default endpoint if we are in the kernel repo topdir = b4.git_get_toplevel() if topdir and os.path.exists(os.path.join(topdir, 'Kconfig')): - logger.debug('No sendemail configs found, will use the default web endpoint') + logger.debug( + 'No sendemail configs found, will use the default web endpoint' + ) endpoint = DEFAULT_ENDPOINT # Cannot currently use endpoint with --preview-to @@ -2102,14 +2378,18 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: if not cmdargs.resend: logger.debug('Running pre-flight checks') sinfo = get_info(usebranch=mybranch) - pfchecks = {'needs-editing': True, - 'needs-checking': True, - 'needs-checking-deps': True, - 'needs-auto-to-cc': True, - } + pfchecks = { + 'needs-editing': True, + 'needs-checking': True, + 'needs-checking-deps': True, + 'needs-auto-to-cc': True, + } _cppfc = config.get('prep-pre-flight-checks', 'enable-all') if not isinstance(_cppfc, str): - logger.critical('CRITICAL: prep-pre-flight-checks must be a str, got %s', type(_cppfc).__name__) + logger.critical( + 'CRITICAL: prep-pre-flight-checks must be a str, got %s', + type(_cppfc).__name__, + ) sys.exit(1) cfg_checks = [x.strip() for x in _cppfc.split(',')] if 'disable-all' in cfg_checks: @@ -2124,7 +2404,11 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: for pfcheck in pfchecks: pfdata = sinfo[pfcheck] if not isinstance(pfdata, bool): - logger.debug('Pre-flight check %s is not a boolean, got %s', pfcheck, type(pfdata).__name__) + logger.debug( + 'Pre-flight check %s is not a boolean, got %s', + pfcheck, + type(pfdata).__name__, + ) continue pfchecks[pfcheck] = pfdata if pfdata and not failing: @@ -2145,7 +2429,9 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: logger.critical(' - Run auto-to-cc : b4 prep --auto-to-cc') try: logger.critical('---') - input('Press Enter to ignore and send anyway or Ctrl-C to abort and fix') + input( + 'Press Enter to ignore and send anyway or Ctrl-C to abort and fix' + ) except KeyboardInterrupt: logger.info('') sys.exit(130) @@ -2157,7 +2443,10 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: for commit, msg in patches: if not msg: continue - logger.info(' %s', re.sub(r'\s+', ' ', b4.LoreMessage.clean_header(msg.get('Subject')))) + logger.info( + ' %s', + re.sub(r'\s+', ' ', b4.LoreMessage.clean_header(msg.get('Subject'))), + ) if commit in pccs: extracc = list() for pair in pccs[commit]: @@ -2172,7 +2461,9 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: logger.info('Ready to:') if endpoint: if cmdargs.reflect: - logger.info(' - send the above messages to just %s (REFLECT MODE)', fromaddr) + logger.info( + ' - send the above messages to just %s (REFLECT MODE)', fromaddr + ) else: logger.info(' - send the above messages to actual recipients') logger.info(' - via web endpoint: %s', endpoint) @@ -2180,9 +2471,13 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: if sconfig.get('from'): fromaddr = sconfig.get('from') if cmdargs.reflect: - logger.info(' - send the above messages to just %s (REFLECT MODE)', fromaddr) + logger.info( + ' - send the above messages to just %s (REFLECT MODE)', fromaddr + ) elif cmdargs.preview_to: - logger.info(' - send the above messages to the recipients listed (PREVIEW MODE)') + logger.info( + ' - send the above messages to the recipients listed (PREVIEW MODE)' + ) else: logger.info(' - send the above messages to actual listed recipients') logger.info(' - with envelope-from: %s', fromaddr) @@ -2190,13 +2485,24 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: smtpserver = str(sconfig.get('smtpserver', 'localhost')) if '/' in smtpserver: logger.info(' - via local command %s', smtpserver) - if cmdargs.reflect and sconfig.get('b4-really-reflect-via') != smtpserver: + if ( + cmdargs.reflect + and sconfig.get('b4-really-reflect-via') != smtpserver + ): logger.critical('---') - logger.critical('CRITICAL: Cowardly refusing to reflect via %s.', smtpserver) - logger.critical(' There is no guarantee that this command will do the right thing') - logger.critical(' and will not send mail to actual addressees.') + logger.critical( + 'CRITICAL: Cowardly refusing to reflect via %s.', smtpserver + ) + logger.critical( + ' There is no guarantee that this command will do the right thing' + ) + logger.critical( + ' and will not send mail to actual addressees.' + ) logger.critical('---') - logger.critical('If you are ABSOLUTELY SURE that this command will do the right thing,') + logger.critical( + 'If you are ABSOLUTELY SURE that this command will do the right thing,' + ) logger.critical('add the following to the [sendemail] section:') logger.critical('b4-really-reflect-via = %s', smtpserver) sys.exit(1) @@ -2209,11 +2515,17 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: logger.info('') if cmdargs.reflect: logger.info('REFLECT MODE:') - logger.info(' The To: and Cc: headers will be fully populated, but the only') - logger.info(' address given to the mail server for actual delivery will be') + logger.info( + ' The To: and Cc: headers will be fully populated, but the only' + ) + logger.info( + ' address given to the mail server for actual delivery will be' + ) logger.info(' %s', fromaddr) logger.info('') - logger.info(' Addresses in To: and Cc: headers will NOT receive this series.') + logger.info( + ' Addresses in To: and Cc: headers will NOT receive this series.' + ) logger.info('') try: input('Press Enter to proceed or Ctrl-C to abort') @@ -2278,20 +2590,31 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: send_msgs.append(msg) if cl_msgid is None: - logger.critical('CRITICAL: Unable to get a clean message-id for the cover letter') + logger.critical( + 'CRITICAL: Unable to get a clean message-id for the cover letter' + ) sys.exit(1) if endpoint: # Web endpoint always requires signing if not sign: - logger.critical('CRITICAL: Web endpoint will be used for sending, but signing is turned off') + logger.critical( + 'CRITICAL: Web endpoint will be used for sending, but signing is turned off' + ) logger.critical(' Please re-enable signing or use SMTP') sys.exit(1) try: - sent = b4.send_mail(None, send_msgs, fromaddr=None, patatt_sign=True, - dryrun=cmdargs.dryrun, output_dir=cmdargs.output_dir, web_endpoint=endpoint, - reflect=cmdargs.reflect) + sent = b4.send_mail( + None, + send_msgs, + fromaddr=None, + patatt_sign=True, + dryrun=cmdargs.dryrun, + output_dir=cmdargs.output_dir, + web_endpoint=endpoint, + reflect=cmdargs.reflect, + ) except RuntimeError as ex: logger.critical('CRITICAL: %s', ex) sys.exit(1) @@ -2304,9 +2627,15 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: sys.exit(1) try: - sent = b4.send_mail(smtp, send_msgs, fromaddr=fromaddr, patatt_sign=sign, - dryrun=cmdargs.dryrun, output_dir=cmdargs.output_dir, - reflect=cmdargs.reflect) + sent = b4.send_mail( + smtp, + send_msgs, + fromaddr=fromaddr, + patatt_sign=sign, + dryrun=cmdargs.dryrun, + output_dir=cmdargs.output_dir, + reflect=cmdargs.reflect, + ) except RuntimeError as ex: logger.critical('CRITICAL: %s', ex) sys.exit(1) @@ -2334,13 +2663,17 @@ def cmd_send(cmdargs: argparse.Namespace) -> None: return if tag_msg is None: - logger.critical('CRITICAL: unable to get tag_msg from %s, not rerolling', mybranch) + logger.critical( + 'CRITICAL: unable to get tag_msg from %s, not rerolling', mybranch + ) return reroll(mybranch, tag_msg, cl_msgid) -def get_sent_tagname(tagbase: str, tagprefix: str, revstr: Union[str, int]) -> Tuple[str, Optional[int]]: +def get_sent_tagname( + tagbase: str, tagprefix: str, revstr: Union[str, int] +) -> Tuple[str, Optional[int]]: revision = None if isinstance(revstr, int): revision = revstr @@ -2362,7 +2695,9 @@ def get_sent_tagname(tagbase: str, tagprefix: str, revstr: Union[str, int]) -> T return f'{tagprefix}{tagbase}-v{revision}', revision -def reroll(mybranch: str, tag_msg: str, msgid: str, tagprefix: str = SENT_TAG_PREFIX) -> None: +def reroll( + mybranch: str, tag_msg: str, msgid: str, tagprefix: str = SENT_TAG_PREFIX +) -> None: # Remove signature chunks = tag_msg.rsplit('\n-- \n') if len(chunks) > 1: @@ -2380,15 +2715,21 @@ def reroll(mybranch: str, tag_msg: str, msgid: str, tagprefix: str = SENT_TAG_PR tagcommit = 'HEAD' try: if strategy == 'commit': - base_commit, start_commit, end_commit = get_series_range(usebranch=mybranch) + base_commit, start_commit, end_commit = get_series_range( + usebranch=mybranch + ) with b4.git_temp_worktree(topdir, base_commit) as gwt: logger.debug('Preparing a sparse worktree') - ecode, out = b4.git_run_command(gwt, ['sparse-checkout', 'set'], logstderr=True) + ecode, out = b4.git_run_command( + gwt, ['sparse-checkout', 'set'], logstderr=True + ) if ecode > 0: logger.critical('Error running sparse-checkout set') logger.critical(out) raise RuntimeError - ecode, out = b4.git_run_command(gwt, ['checkout', '-f'], logstderr=True) + ecode, out = b4.git_run_command( + gwt, ['checkout', '-f'], logstderr=True + ) if ecode > 0: logger.critical('Error running checkout into sparse workdir') logger.critical(out) @@ -2397,7 +2738,9 @@ def reroll(mybranch: str, tag_msg: str, msgid: str, tagprefix: str = SENT_TAG_PR ecode, out = b4.git_run_command(gwt, gitargs, logstderr=True) if ecode > 0: # In theory, this shouldn't happen - logger.critical('Unable to cleanly apply series, see failure log below') + logger.critical( + 'Unable to cleanly apply series, see failure log below' + ) logger.critical('---') logger.critical(out.strip()) logger.critical('---') @@ -2492,7 +2835,9 @@ def show_revision() -> None: logger.info(' %s: %s', rn, config['linkmask'] % link) -def write_to_tar(bio_tar: tarfile.TarFile, name: str, mtime: int, bio_file: io.BytesIO) -> None: +def write_to_tar( + bio_tar: tarfile.TarFile, name: str, mtime: int, bio_file: io.BytesIO +) -> None: tifo = tarfile.TarInfo(name) tuser = os.environ.get('USERNAME', 'user') tuid = os.getuid() @@ -2551,7 +2896,9 @@ def _cleanup_branch(branch: str) -> None: logger.info('branch: %s', branch) if 'history' in ts: for rn in ts['history']: - tagname, revision = get_sent_tagname(ts.get('change-id'), SENT_TAG_PREFIX, rn) + tagname, revision = get_sent_tagname( + ts.get('change-id'), SENT_TAG_PREFIX, rn + ) tag_commit = b4.git_revparse_tag(None, tagname) if not tag_commit: tagname, revision = get_sent_tagname(branch, SENT_TAG_PREFIX, rn) @@ -2572,32 +2919,43 @@ def _cleanup_branch(branch: str) -> None: resp = None while resp is None: resp = input('Proceed? [y/s/q/N/?] ') - if resp == "?": - logger.info(textwrap.dedent( - """ + if resp == '?': + logger.info( + textwrap.dedent( + """ Possible answers: y: cleanup the branch s: show branch log q or Ctrl-C: abort cleanup n (default): do not cleanup this branch ?: show this help message - """)) + """ + ) + ) resp = None - elif resp in ("show", "s"): - ecode, out = b4.git_run_command(None, ["log", - "--patch", - "--color=always", - f"{start_commit}~..{end_commit}"]) + elif resp in ('show', 's'): + ecode, out = b4.git_run_command( + None, + [ + 'log', + '--patch', + '--color=always', + f'{start_commit}~..{end_commit}', + ], + ) if ecode > 0: - logger.critical('ERROR: unable to show git log between %s and %s', - start_commit, end_commit) + logger.critical( + 'ERROR: unable to show git log between %s and %s', + start_commit, + end_commit, + ) sys.exit(130) logger.info(out) logger.info('') resp = None - elif resp == "q": + elif resp == 'q': sys.exit(130) - elif resp != "y": + elif resp != 'y': return except KeyboardInterrupt: @@ -2632,19 +2990,28 @@ def _cleanup_branch(branch: str) -> None: for tagname, base_commit, tag_commit, revision, cover in tags: logger.info('Archiving %s', tagname) # use tag date as mtime - lines = b4.git_get_command_lines(None, ['log', '-1', '--format=%ct', tagname]) + lines = b4.git_get_command_lines( + None, ['log', '-1', '--format=%ct', tagname] + ) if not lines: logger.critical('Could not get tag date for %s', tagname) sys.exit(1) mtime = int(lines[0]) ifh = io.BytesIO() ifh.write(cover.encode()) - write_to_tar(tfh, f'{change_id}/{SENT_TAG_PREFIX}patches-v{revision}.cover', mtime, ifh) + write_to_tar( + tfh, + f'{change_id}/{SENT_TAG_PREFIX}patches-v{revision}.cover', + mtime, + ifh, + ) ifh.close() patches = b4.git_range_to_patches(None, base_commit, tag_commit) ifh = io.BytesIO() b4.save_git_am_mbox([patch[1] for patch in patches], ifh) - write_to_tar(tfh, f'{change_id}/{SENT_TAG_PREFIX}patches-v{revision}.mbx', mtime, ifh) + write_to_tar( + tfh, f'{change_id}/{SENT_TAG_PREFIX}patches-v{revision}.mbx', mtime, ifh + ) deletes.append(['tag', '--delete', tagname]) # Write in data_dir @@ -2726,8 +3093,12 @@ def get_info(usebranch: str) -> Dict[str, Union[str, bool, None]]: cover, tracking = load_cover(usebranch=usebranch) csubject, _ = get_cover_subject_body(cover) ts = tracking['series'] - base_commit, start_commit, end_commit, oneline, _shortlog, _diffstat = get_series_details(usebranch=usebranch) - todests, ccdests, _, patches = get_prep_branch_as_patches(usebranch=usebranch, expandprereqs=False) + base_commit, start_commit, end_commit, oneline, _shortlog, _diffstat = ( + get_series_details(usebranch=usebranch) + ) + todests, ccdests, _, patches = get_prep_branch_as_patches( + usebranch=usebranch, expandprereqs=False + ) prereqs = tracking['series'].get('prerequisites', list()) tocmd, cccmd = get_auto_to_cc_cmds() ppcmds, scmds = get_check_cmds() @@ -2742,13 +3113,11 @@ def get_info(usebranch: str) -> Dict[str, Union[str, bool, None]]: 'start-commit': start_commit, 'end-commit': end_commit, 'series-range': f'{start_commit}..{end_commit}', - # General information about this branch status 'prefixes': ' '.join(ts.get('prefixes', [])) or None, 'change-id': ts.get('change-id'), 'revision': ts.get('revision'), 'cover-strategy': get_cover_strategy(usebranch=usebranch), - # General information about this branch checks 'needs-editing': b'EDITME' in b4.LoreMessage.get_msg_as_bytes(patches[0][1]), 'needs-recipients': bool(not todests and not ccdests), @@ -2758,9 +3127,15 @@ def get_info(usebranch: str) -> Dict[str, Union[str, bool, None]]: 'needs-checking-deps': len(prereqs) > 0 and 'check-deps' not in pf_checks, 'preflight-checks-failing': None, } - info['needs-auto-to-cc'] = info["needs-recipients"] or (bool(tocmd or cccmd) and 'auto-to-cc' not in pf_checks) - info['preflight-checks-failing'] = bool(info['needs-editing'] or info['needs-auto-to-cc'] or - info['needs-checking'] or info['needs-checking-deps']) + info['needs-auto-to-cc'] = info['needs-recipients'] or ( + bool(tocmd or cccmd) and 'auto-to-cc' not in pf_checks + ) + info['preflight-checks-failing'] = bool( + info['needs-editing'] + or info['needs-auto-to-cc'] + or info['needs-checking'] + or info['needs-checking-deps'] + ) # Add informations about the commits in this series # `commit-<hash>`: stores the subject of each commit @@ -2770,10 +3145,14 @@ def get_info(usebranch: str) -> Dict[str, Union[str, bool, None]]: info[f'commit-{short}'] = subject if 'history' in ts: for rn, links in reversed(ts['history'].items()): - tagname, revision = get_sent_tagname(ts.get('change-id'), SENT_TAG_PREFIX, rn) + tagname, revision = get_sent_tagname( + ts.get('change-id'), SENT_TAG_PREFIX, rn + ) tag_commit = b4.git_revparse_tag(None, tagname) if not tag_commit: - logger.debug('No tag %s, trying with base branch name %s', tagname, usebranch) + logger.debug( + 'No tag %s, trying with base branch name %s', tagname, usebranch + ) tagname, revision = get_sent_tagname(usebranch, SENT_TAG_PREFIX, rn) tag_commit = b4.git_revparse_tag(None, tagname) if not tag_commit: @@ -2781,7 +3160,11 @@ def get_info(usebranch: str) -> Dict[str, Union[str, bool, None]]: continue try: cover, base_commit, _change_id = get_base_changeid_from_tag(tagname) - info[f'series-{rn}'] = '%s..%s %s' % (base_commit[:12], tag_commit[:12], links[0]) + info[f'series-{rn}'] = '%s..%s %s' % ( + base_commit[:12], + tag_commit[:12], + links[0], + ) except RuntimeError as ex: logger.debug('Could not get base-commit info from %s: %s', tagname, ex) return info @@ -2794,10 +3177,14 @@ def force_revision(forceto: int) -> None: store_cover(cover, tracking) -def range_diff_compare(compareto: str, execvp: bool = True, range_diff_opts: Optional[str] = None) -> Union[str, None]: +def range_diff_compare( + compareto: str, execvp: bool = True, range_diff_opts: Optional[str] = None +) -> Union[str, None]: _, tracking = load_cover() # Try the new format first - tagname, _ = get_sent_tagname(tracking['series']['change-id'], SENT_TAG_PREFIX, compareto) + tagname, _ = get_sent_tagname( + tracking['series']['change-id'], SENT_TAG_PREFIX, compareto + ) prev_end = b4.git_revparse_tag(None, tagname) if not prev_end: mybranch = b4.git_get_current_branch(None) @@ -2826,7 +3213,12 @@ def range_diff_compare(compareto: str, execvp: bool = True, range_diff_opts: Opt gitargs = ['rev-parse', series_end] lines = b4.git_get_command_lines(None, gitargs) curr_end = lines[0] - grdcmd = ['git', 'range-diff', '%.12s..%.12s' % (prev_start, prev_end), '%.12s..%.12s' % (curr_start, curr_end)] + grdcmd = [ + 'git', + 'range-diff', + '%.12s..%.12s' % (prev_start, prev_end), + '%.12s..%.12s' % (curr_start, curr_end), + ] if range_diff_opts: sp = shlex.shlex(range_diff_opts, posix=True) sp.whitespace_split = True @@ -2897,7 +3289,10 @@ def auto_to_cc() -> None: logger.debug('added %s to seen', ltr.addr[1]) extras = list() - for tname, addrs in (('To', config.get('send-series-to')), ('Cc', config.get('send-series-cc'))): + for tname, addrs in ( + ('To', config.get('send-series-to')), + ('Cc', config.get('send-series-cc')), + ): if not addrs or not isinstance(addrs, str): continue for pair in email.utils.getaddresses([addrs]): @@ -2923,8 +3318,10 @@ def auto_to_cc() -> None: logger.debug('Collecting from: %s', msg.get('subject')) msgbytes = msg.as_bytes() - for tname, pairs in (('To', get_addresses_from_cmd(tocmd, msgbytes)), - ('Cc', get_addresses_from_cmd(cccmd, msgbytes))): + for tname, pairs in ( + ('To', get_addresses_from_cmd(tocmd, msgbytes)), + ('Cc', get_addresses_from_cmd(cccmd, msgbytes)), + ): for pair in pairs: if pair[1] not in seen: seen.add(pair[1]) @@ -2954,8 +3351,13 @@ def get_preflight_hash(usebranch: Optional[str] = None) -> str: global PFHASH_CACHE cachebranch = usebranch if usebranch is not None else '_current_' if cachebranch not in PFHASH_CACHE: - _tos, _ccs, _tstr, patches = get_prep_branch_as_patches(movefrom=False, thread=False, addtracking=False, - usebranch=usebranch, expandprereqs=False) + _tos, _ccs, _tstr, patches = get_prep_branch_as_patches( + movefrom=False, + thread=False, + addtracking=False, + usebranch=usebranch, + expandprereqs=False, + ) hashed = hashlib.sha1() for _commit, msg in patches: body, _charset = b4.LoreMessage.get_payload(msg) @@ -3010,13 +3412,13 @@ def set_prefixes(prefixes: List[str], additive: bool = False) -> None: def _check_presubject(presubject: str) -> None: - if presubject == "": + if presubject == '': return - if presubject.startswith("[") and presubject.endswith("]"): + if presubject.startswith('[') and presubject.endswith(']'): return - raise RuntimeError("The presubject must be enclosed with brackets. E.g: [mylist]") + raise RuntimeError('The presubject must be enclosed with brackets. E.g: [mylist]') def set_presubject(presubject: str) -> None: @@ -3025,7 +3427,7 @@ def set_presubject(presubject: str) -> None: tracking['series']['presubject'] = presubject if tracking['series']['presubject'] != old_presubject: store_cover(cover, tracking) - if tracking['series']['presubject'] != "": + if tracking['series']['presubject'] != '': logger.info('Updated pre-subject to: %s', presubject) else: logger.info('Removed pre-subject.') @@ -3080,7 +3482,9 @@ def cmd_prep(cmdargs: argparse.Namespace) -> None: return if cmdargs.enroll_base and cmdargs.new_series_name: - logger.critical('CRITICAL: -n NEW_SERIES_NAME and -e [ENROLL_BASE] can not be used together.') + logger.critical( + 'CRITICAL: -n NEW_SERIES_NAME and -e [ENROLL_BASE] can not be used together.' + ) sys.exit(1) if cmdargs.enroll_base or cmdargs.new_series_name: @@ -3088,14 +3492,26 @@ def cmd_prep(cmdargs: argparse.Namespace) -> None: # We only support this with the commit strategy strategy = get_cover_strategy() if strategy != 'commit': - logger.critical('CRITICAL: This appears to already be a b4-prep managed branch.') - logger.critical(' Chaining series is only supported with the "commit" strategy.') - logger.critical(' Switch to a different branch or use the -f flag to continue.') + logger.critical( + 'CRITICAL: This appears to already be a b4-prep managed branch.' + ) + logger.critical( + ' Chaining series is only supported with the "commit" strategy.' + ) + logger.critical( + ' Switch to a different branch or use the -f flag to continue.' + ) sys.exit(1) - logger.critical('IMPORTANT: This appears to already be a b4-prep managed branch.') - logger.critical(' The new branch will be marked as depending on this series.') - logger.critical(' Alternatively, switch to a different branch or use the -f flag.') + logger.critical( + 'IMPORTANT: This appears to already be a b4-prep managed branch.' + ) + logger.critical( + ' The new branch will be marked as depending on this series.' + ) + logger.critical( + ' Alternatively, switch to a different branch or use the -f flag.' + ) try: input('Press Enter to confirm or Ctrl-C to abort') logger.info('---') diff --git a/src/b4/kr.py b/src/b4/kr.py index 8bbfe26..f89f302 100644 --- a/src/b4/kr.py +++ b/src/b4/kr.py @@ -58,10 +58,14 @@ def main(cmdargs: argparse.Namespace) -> None: ecc = False for identity, algo, selector, keyinfo in keydata: if not identity: - logger.warning('No identity found for key %s %s %s', algo, selector, keyinfo) + logger.warning( + 'No identity found for key %s %s %s', algo, selector, keyinfo + ) continue if not keyinfo: - logger.warning('No keyinfo found for key %s %s %s', algo, selector, identity) + logger.warning( + 'No keyinfo found for key %s %s %s', algo, selector, identity + ) continue keypath = patatt.make_pkey_path(algo, identity, selector) fullpath = os.path.join(krpath, keypath) diff --git a/src/b4/mbox.py b/src/b4/mbox.py index 624a2f3..3198836 100644 --- a/src/b4/mbox.py +++ b/src/b4/mbox.py @@ -38,7 +38,9 @@ Link: ${midurl} """ -def save_msgs_as_mbox(dest: str, msgs: List[EmailMessage], filterdupes: bool = False) -> int: +def save_msgs_as_mbox( + dest: str, msgs: List[EmailMessage], filterdupes: bool = False +) -> int: if dest == '-': b4.save_mboxrd_mbox(msgs, sys.stdout.buffer, mangle_from=False) return len(msgs) @@ -62,12 +64,15 @@ def save_msgs_as_mbox(dest: str, msgs: List[EmailMessage], filterdupes: bool = F return len(msgs) -def get_base_commit(topdir: Optional[str], body: str, lser: b4.LoreSeries, - cmdargs: argparse.Namespace) -> str: +def get_base_commit( + topdir: Optional[str], body: str, lser: b4.LoreSeries, cmdargs: argparse.Namespace +) -> str: base_commit = 'HEAD' if lser.prereq_base_commit: - logger.debug('Setting base-commit to prereq-base-commit: %s', lser.prereq_base_commit) + logger.debug( + 'Setting base-commit to prereq-base-commit: %s', lser.prereq_base_commit + ) base_commit = lser.prereq_base_commit else: matches = re.search(r'base-commit: .*?([\da-f]+)', body, re.MULTILINE) @@ -90,22 +95,30 @@ def get_base_commit(topdir: Optional[str], body: str, lser: b4.LoreSeries, if base_commit == 'HEAD' and topdir and cmdargs.guessbase: logger.info(' Base: attempting to guess base-commit...') try: - base_commit, nblobs, mismatches = lser.find_base(topdir, branches=cmdargs.guessbranch, - maxdays=cmdargs.guessdays) + 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) + logger.critical( + ' Base: %s (best guess, %s/%s blobs matched)', + base_commit, + nblobs - mismatches, + nblobs, + ) except IndexError as ex: logger.critical(' Base: failed to guess base (%s)', ex) if cmdargs.mergebase: if base_commit: - logger.debug(' Base: overriding submitter provided base-commit %s with %s', - base_commit, cmdargs.mergebase) + logger.debug( + ' Base: overriding submitter provided base-commit %s with %s', + base_commit, + cmdargs.mergebase, + ) base_commit = cmdargs.mergebase return base_commit @@ -150,15 +163,22 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - payload = bpayload.decode('utf-8', errors='replace') part.set_param('charset', 'utf-8') if payload and b4.DIFF_RE.search(payload): - xmsg = email.parser.Parser(policy=b4.emlpolicy, _class=EmailMessage).parsestr(payload) + xmsg = email.parser.Parser( + policy=b4.emlpolicy, _class=EmailMessage + ).parsestr(payload) # Needs to have Subject, From, Date for us to consider it if xmsg.get('Subject') and xmsg.get('From') and xmsg.get('Date'): logger.debug('Found attached patch: %s', xmsg.get('Subject')) xmsg['Message-ID'] = f'<att{len(xpatches)}-{xmsgid}>' xpatches.append(xmsg) if len(xpatches): - logger.info('Warning: Found %s patches attached to the requested message', len(xpatches)) - logger.info(' This mode ignores any follow-up trailers, use with caution') + logger.info( + 'Warning: Found %s patches attached to the requested message', + len(xpatches), + ) + logger.info( + ' This mode ignores any follow-up trailers, use with caution' + ) # Throw out lmbx and only use these lmbx = b4.LoreMailbox() load_codereview = False @@ -181,8 +201,12 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - if cmdargs.nopartialreroll: reroll = False - lser = lmbx.get_series(revision=wantver, sloppytrailers=cmdargs.sloppytrailers, reroll=reroll, - codereview_trailers=load_codereview) + lser = lmbx.get_series( + revision=wantver, + sloppytrailers=cmdargs.sloppytrailers, + reroll=reroll, + codereview_trailers=load_codereview, + ) if lser is None and cmdargs.cherrypick != '_': if wantver is None: logger.critical('No patches found.') @@ -214,7 +238,9 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - cmdargs.cherrypick = f'<{msgid}>' break if not len(cherrypick): - logger.critical('Specified msgid is not present in the series, cannot cherrypick') + logger.critical( + 'Specified msgid is not present in the series, cannot cherrypick' + ) sys.exit(1) elif cmdargs.cherrypick.find('*') >= 0: # Globbing on subject @@ -226,22 +252,35 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - 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) + 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)) + cherrypick = list( + b4.parse_int_range(cmdargs.cherrypick, upper=len(lser.patches) - 1) + ) else: cherrypick = None - am_msgs = lser.get_am_ready(noaddtrailers=cmdargs.noaddtrailers, addmysob=cmdargs.addmysob, addlink=cmdargs.addlink, - cherrypick=cherrypick, copyccs=cmdargs.copyccs, allowbadchars=cmdargs.allowbadchars, - showchecks=cmdargs.check) + am_msgs = lser.get_am_ready( + noaddtrailers=cmdargs.noaddtrailers, + addmysob=cmdargs.addmysob, + addlink=cmdargs.addlink, + cherrypick=cherrypick, + copyccs=cmdargs.copyccs, + allowbadchars=cmdargs.allowbadchars, + showchecks=cmdargs.check, + ) 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) + logger.info( + 'Total patches: %s (cherrypicked: %s)', len(am_msgs), cmdargs.cherrypick + ) if len(lser.trailer_mismatches): logger.critical('---') @@ -274,12 +313,20 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - if mismatches: rstart, rend = lser.make_fake_am_range(gitdir=None) if rstart and rend: - logger.info('Prepared fake commit range for 3-way merge (%.12s..%.12s)', rstart, rend) + logger.info( + 'Prepared 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( + '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: @@ -341,12 +388,17 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - if cmdargs.subcmd == 'shazam': if not topdir: - logger.critical('Could not figure out where your git dir is, cannot shazam.') + logger.critical( + 'Could not figure out where your git dir is, cannot shazam.' + ) sys.exit(1) ifh = io.BytesIO() if lser.prereq_patch_ids: - logger.info(' Deps: looking for dependencies matching %s patch-ids', len(lser.prereq_patch_ids)) + logger.info( + ' Deps: looking for dependencies matching %s patch-ids', + len(lser.prereq_patch_ids), + ) query = ' OR '.join([f'patchid:{x}' for x in lser.prereq_patch_ids]) logger.debug('query=%s', query) dmsgs = b4.get_pi_search_results(query) @@ -362,7 +414,10 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - pmap[dlmsg.git_patch_id] = dlmsg for ppid in lser.prereq_patch_ids: if ppid in pmap: - logger.info(' Deps: Applying prerequisite patch: %s', pmap[ppid].full_subject) + logger.info( + ' Deps: Applying prerequisite patch: %s', + pmap[ppid].full_subject, + ) pam_msg = pmap[ppid].get_am_message(add_trailers=False) b4.save_mboxrd_mbox([pam_msg], ifh) @@ -373,7 +428,9 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - sp = shlex.shlex(amflags, posix=True) sp.whitespace_split = True amargs = list(sp) + ['--patch-format=mboxrd'] - ecode, out = b4.git_run_command(topdir, ['am'] + amargs, stdin=ambytes, logstderr=True, rundir=topdir) + ecode, out = b4.git_run_command( + topdir, ['am'] + amargs, stdin=ambytes, logstderr=True, rundir=topdir + ) logger.info(out.strip()) if ecode == 0: thanks_record_am(lser, cherrypick=cherrypick) @@ -388,8 +445,10 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - try: merge_template = b4.read_template(str(config['shazam-merge-template'])) except FileNotFoundError: - logger.critical('ERROR: shazam-merge-template says to use %s, but it does not exist', - config['shazam-merge-template']) + logger.critical( + 'ERROR: shazam-merge-template says to use %s, but it does not exist', + config['shazam-merge-template'], + ) sys.exit(2) if lser.has_cover and lser.patches[0] is not None: @@ -398,12 +457,16 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - covermessage = parts[1] else: if lser.patches[1] is None: - logger.critical('No cover letter provided by the author and no first patch, cannot shazam') + logger.critical( + 'No cover letter provided by the author and no first patch, cannot shazam' + ) sys.exit(1) clmsg = lser.patches[1] - covermessage = ('NOTE: No cover letter provided by the author.\n' - ' Add merge commit message here.') + covermessage = ( + 'NOTE: No cover letter provided by the author.\n' + ' Add merge commit message here.' + ) tptvals = { 'seriestitle': clmsg.subject, @@ -432,8 +495,13 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - logger.info(' Base: %s', base_commit) else: logger.info(' Base: %s (use --merge-base to override)', base_commit) - b4.git_fetch_am_into_repo(topdir, ambytes=ambytes, at_base=base_commit, - origin=linkurl, am_flags=am_flags) + b4.git_fetch_am_into_repo( + topdir, + ambytes=ambytes, + at_base=base_commit, + origin=linkurl, + am_flags=am_flags, + ) except b4.AmConflictError as cex: gwt = cex.worktree_path if not getattr(cmdargs, 'shazam_resolve', False): @@ -522,7 +590,9 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) - logger.critical(' git checkout -b %s %s', gitbranch, base_commit) if cmdargs.outdir != '-': - logger.critical(' git am %s%s', '-3 ' if cmdargs.threeway else '', am_filename) + logger.critical( + ' git am %s%s', '-3 ' if cmdargs.threeway else '', am_filename + ) thanks_record_am(lser, cherrypick=cherrypick) @@ -561,7 +631,9 @@ def thanks_record_am(lser: b4.LoreSeries, cherrypick: Optional[List[int]]) -> No msgids.append(pmsg.msgid) if pmsg.pwhash is None: - logger.debug('Unable to get hashes for all patches, not tracking for thanks') + logger.debug( + 'Unable to get hashes for all patches, not tracking for thanks' + ) return prefix = '%s/%s' % (str(pmsg.counter).zfill(padlen), pmsg.expected) @@ -610,20 +682,28 @@ def thanks_record_am(lser: b4.LoreSeries, cherrypick: Optional[List[int]]) -> No def save_as_quilt(am_msgs: List[EmailMessage], q_dirname: str) -> None: if os.path.exists(q_dirname): - logger.critical('ERROR: Directory %s exists, not saving quilt patches', 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()) + 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())) + 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()) @@ -638,8 +718,12 @@ def save_as_quilt(am_msgs: List[EmailMessage], q_dirname: str) -> None: sfh.write('%s\n' % patch_filename) -def get_extra_series(msgs: List[EmailMessage], direction: int = 1, wantvers: Optional[List[int]] = None, - nocache: bool = False) -> List[EmailMessage]: +def get_extra_series( + msgs: List[EmailMessage], + direction: int = 1, + wantvers: Optional[List[int]] = None, + nocache: bool = False, +) -> List[EmailMessage]: base_msg: Optional[EmailMessage] = None latest_revision: Optional[int] = None seen_msgids: Set[str] = set() @@ -720,8 +804,9 @@ def get_extra_series(msgs: List[EmailMessage], direction: int = 1, wantvers: Opt logger.critical('Checking for older revisions') # Cap backward search to 12 months to avoid matching years of # identically-named series (common with subject+from fallback). - earliest = time.strftime('%Y%m%d', time.gmtime( - time.mktime(msgdate[:9]) - 365 * 86400)) + earliest = time.strftime( + '%Y%m%d', time.gmtime(time.mktime(msgdate[:9]) - 365 * 86400) + ) datelim = 'd:%s..%s' % (earliest, startdate) q = '(%s) AND %s' % (' OR '.join(queries), datelim) @@ -756,7 +841,9 @@ def get_extra_series(msgs: List[EmailMessage], direction: int = 1, wantvers: Opt logger.debug('Ignoring result (not old revision): %s', lsub.full_subject) continue if direction < 0 and wantvers and lsub.revision not in wantvers: - logger.debug('Ignoring result (not revision we want): %s', lsub.full_subject) + logger.debug( + 'Ignoring result (not revision we want): %s', lsub.full_subject + ) continue if lsub.revision == 1 and lsub.revision == latest_revision: @@ -768,9 +855,15 @@ def get_extra_series(msgs: List[EmailMessage], direction: int = 1, wantvers: Opt # It's *probably* an older revision. logger.debug('Likely an older revision: %s', lsub.full_subject) elif direction > 0 and lsub.revision > latest_revision: - logger.debug('Definitely a new revision [v%s]: %s', lsub.revision, lsub.full_subject) + logger.debug( + 'Definitely a new revision [v%s]: %s', lsub.revision, lsub.full_subject + ) elif direction < 0 and lsub.revision < latest_revision: - logger.debug('Definitely an older revision [v%s]: %s', lsub.revision, lsub.full_subject) + logger.debug( + 'Definitely an older revision [v%s]: %s', + lsub.revision, + lsub.full_subject, + ) else: logger.debug('No idea what this is: %s', lsub.subject) continue @@ -793,7 +886,9 @@ def get_extra_series(msgs: List[EmailMessage], direction: int = 1, wantvers: Opt if not payload: continue for cid in change_ids: - if re.search(rf'^change-id:\s*{re.escape(cid)}\s*$', payload, flags=re.I | re.M): + if re.search( + rf'^change-id:\s*{re.escape(cid)}\s*$', payload, flags=re.I | re.M + ): lsub = b4.LoreSubject(q_msg.get('Subject', '')) valid_revisions.add(lsub.revision) break @@ -855,13 +950,13 @@ def refetch(dest: str) -> None: def minimize_thread(msgs: List[EmailMessage]) -> List[EmailMessage]: # We go through each message and minimize headers and body content wanthdrs = { - 'From', - 'Subject', - 'Date', - 'Message-ID', - 'Reply-To', - 'In-Reply-To', - } + 'From', + 'Subject', + 'Date', + 'Message-ID', + 'Reply-To', + 'In-Reply-To', + } mmsgs = list() for msg in msgs: mmsg = EmailMessage() @@ -877,7 +972,7 @@ def minimize_thread(msgs: List[EmailMessage]) -> List[EmailMessage]: chunks: List[Tuple[bool, List[str]]] = list() chunk: List[str] = list() current = None - for line in (cmsg.rstrip().splitlines()): + for line in cmsg.rstrip().splitlines(): quoted = line.startswith('>') and True or False if current is None: current = quoted @@ -922,8 +1017,9 @@ def minimize_thread(msgs: List[EmailMessage]) -> List[EmailMessage]: return mmsgs -def _start_merge_resolve(topdir: str, cex: b4.AmConflictError, - common_dir: str, state: Dict[str, Any]) -> None: +def _start_merge_resolve( + topdir: str, cex: b4.AmConflictError, common_dir: str, state: Dict[str, Any] +) -> None: gwt = cex.worktree_path logger.critical('---') logger.critical(cex.output) @@ -931,8 +1027,9 @@ def _start_merge_resolve(topdir: str, cex: b4.AmConflictError, logger.critical('Patch series did not apply cleanly, resolving...') # Find rebase-apply in the worktree - ecode, gitdir = b4.git_run_command(gwt, ['rev-parse', '--git-dir'], - logstderr=True, rundir=gwt) + ecode, gitdir = b4.git_run_command( + gwt, ['rev-parse', '--git-dir'], logstderr=True, rundir=gwt + ) if ecode > 0: logger.critical('Unable to find git directory in worktree') b4.git_run_command(topdir, ['worktree', 'remove', '--force', gwt]) @@ -1011,8 +1108,12 @@ def _start_merge_resolve(topdir: str, cex: b4.AmConflictError, # Start merge of successfully applied patches logger.info('Merging successfully applied patches into your branch...') - ecode, out = b4.git_run_command(topdir, ['merge', '--no-ff', '--no-commit', 'FETCH_HEAD'], - logstderr=True, rundir=topdir) + ecode, out = b4.git_run_command( + topdir, + ['merge', '--no-ff', '--no-commit', 'FETCH_HEAD'], + logstderr=True, + rundir=topdir, + ) if ecode > 0: logger.warning('Merge had conflicts:') @@ -1026,8 +1127,13 @@ def _start_merge_resolve(topdir: str, cex: b4.AmConflictError, sys.exit(0) -def _apply_remaining_patches(topdir: str, patches_dir: str, state: Dict[str, Any], - state_file: str, common_dir: str) -> None: +def _apply_remaining_patches( + topdir: str, + patches_dir: str, + state: Dict[str, Any], + state_file: str, + common_dir: str, +) -> None: with open(os.path.join(patches_dir, 'total'), 'r') as fh: total = int(fh.read().strip()) with open(os.path.join(patches_dir, 'current'), 'r') as fh: @@ -1043,14 +1149,19 @@ def _apply_remaining_patches(topdir: str, patches_dir: str, state: Dict[str, Any patch_data = fh.read() logger.info('Applying remaining patch %d/%d...', current + 1, total) - ecode, out = b4.git_run_command(topdir, ['apply', '--3way'], - stdin=patch_data, logstderr=True, rundir=topdir) + ecode, out = b4.git_run_command( + topdir, ['apply', '--3way'], stdin=patch_data, logstderr=True, rundir=topdir + ) if ecode > 0: logger.critical('---') logger.critical(out.strip()) logger.critical('---') - logger.critical('Remaining patch %d/%d did not apply cleanly.', current + 1, total) - logger.critical('Resolve conflicts in your working tree, then run: b4 shazam --continue') + logger.critical( + 'Remaining patch %d/%d did not apply cleanly.', current + 1, total + ) + logger.critical( + 'Resolve conflicts in your working tree, then run: b4 shazam --continue' + ) logger.critical('To abort: b4 shazam --abort') # Advance past this patch, its changes (with conflict markers) are in the tree with open(os.path.join(patches_dir, 'current'), 'w') as fh: @@ -1067,8 +1178,13 @@ def _apply_remaining_patches(topdir: str, patches_dir: str, state: Dict[str, Any _finish_shazam_merge(topdir, state, state_file, common_dir, patches_dir) -def _finish_shazam_merge(topdir: str, state: Dict[str, Any], state_file: str, - common_dir: str, patches_dir: str) -> None: +def _finish_shazam_merge( + topdir: str, + state: Dict[str, Any], + state_file: str, + common_dir: str, + patches_dir: str, +) -> None: b4.git_run_command(topdir, ['add', '-u'], logstderr=True, rundir=topdir) gitargs = ['rev-parse', '--git-dir'] @@ -1101,7 +1217,9 @@ def _finish_shazam_merge(topdir: str, state: Dict[str, Any], state_file: str, commitargs.extend(list(sp)) if no_interactive: commitargs.append('--no-edit') - ecode, out = b4.git_run_command(topdir, commitargs, logstderr=True, rundir=topdir) + ecode, out = b4.git_run_command( + topdir, commitargs, logstderr=True, rundir=topdir + ) if ecode > 0: logger.critical('Failed to commit merge:') logger.critical(out.strip()) @@ -1123,7 +1241,9 @@ def _finish_shazam_merge(topdir: str, state: Dict[str, Any], state_file: str, logger.info('Merge completed successfully.') -def _load_shazam_state(require_state: bool = True) -> Tuple[str, str, str, Optional[Dict[str, Any]]]: +def _load_shazam_state( + require_state: bool = True, +) -> Tuple[str, str, str, Optional[Dict[str, Any]]]: topdir = b4.git_get_toplevel() if not topdir: logger.critical('Could not figure out where your git dir is.') @@ -1159,8 +1279,12 @@ def shazam_continue(cmdargs: argparse.Namespace) -> None: b4.git_run_command(topdir, ['add', '-u'], logstderr=True, rundir=topdir) # Check for remaining unmerged files - _ecode, unmerged = b4.git_run_command(topdir, ['diff', '--name-only', '--diff-filter=U'], - logstderr=True, rundir=topdir) + _ecode, unmerged = b4.git_run_command( + topdir, + ['diff', '--name-only', '--diff-filter=U'], + logstderr=True, + rundir=topdir, + ) if unmerged.strip(): logger.critical('There are still unresolved conflicts:') logger.critical(unmerged.strip()) diff --git a/src/b4/pr.py b/src/b4/pr.py index cb2ca76..7c0659a 100644 --- a/src/b4/pr.py +++ b/src/b4/pr.py @@ -46,7 +46,11 @@ PULL_BODY_REMOTE_REF_RE = [ def git_get_commit_id_from_repo_ref(repo: str, ref: str) -> Optional[str]: # We only handle git and http/s URLs - if not (repo.find('git://') == 0 or repo.find('http://') == 0 or repo.find('https://') == 0): + 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 @@ -56,10 +60,14 @@ def git_get_commit_id_from_repo_ref(repo: str, ref: str) -> Optional[str]: # 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]) + 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]) + lines = b4.git_get_command_lines( + None, ['ls-remote', repo, 'refs/tags/%s^{}' % ref] + ) elif ref.find('tags/') == 0: # try as an annotated tag first @@ -114,7 +122,9 @@ def parse_pr_data(msg: email.message.EmailMessage) -> Optional[b4.LoreMessage]: 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) + lmsg.pr_remote_tip_commit = git_get_commit_id_from_repo_ref( + lmsg.pr_repo, lmsg.pr_ref + ) return lmsg @@ -136,9 +146,13 @@ def attest_fetch_head(gitdir: Optional[str], lmsg: b4.LoreMessage) -> None: if len(htype): otype = htype[0] if otype == 'tag': - _ecode, out = b4.git_run_command(gitdir, ['verify-tag', '--raw', 'FETCH_HEAD'], logstderr=True) + _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) + _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 @@ -172,7 +186,9 @@ def attest_fetch_head(gitdir: Optional[str], lmsg: b4.LoreMessage) -> None: if errors: logger.critical(' ---') if len(out): - logger.critical(' Pull request is signed, but verification did not succeed:') + logger.critical( + ' Pull request is signed, but verification did not succeed:' + ) else: logger.critical(' Pull request verification did not succeed:') for error in errors: @@ -180,24 +196,36 @@ def attest_fetch_head(gitdir: Optional[str], lmsg: b4.LoreMessage) -> None: if attpolicy == 'hardfail': import sys + sys.exit(128) -def fetch_remote(gitdir: Optional[str], lmsg: b4.LoreMessage, branch: Optional[str] = None, - check_sig: bool = True, ty_track: bool = True) -> int: +def fetch_remote( + gitdir: Optional[str], + lmsg: b4.LoreMessage, + branch: Optional[str] = None, + check_sig: bool = True, + ty_track: bool = True, +) -> int: # Do we know anything about this base commit? if lmsg.pr_base_commit and not b4.git_commit_exists(gitdir, lmsg.pr_base_commit): logger.critical('ERROR: git knows nothing about commit %s', lmsg.pr_base_commit) - logger.critical(' Are you running inside a git checkout and is it up-to-date?') + 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) + logger.critical( + ' msg=%s, remote=%s', lmsg.pr_tip_commit, lmsg.pr_remote_tip_commit + ) return 1 if not lmsg.pr_repo or not lmsg.pr_ref: - logger.critical('ERROR: Could not find remote repository or ref in pull request') + logger.critical( + 'ERROR: Could not find remote repository or ref in pull request' + ) logger.critical(' msgid=%s', lmsg.msgid) return 1 @@ -252,7 +280,7 @@ def thanks_record_pr(lmsg: b4.LoreMessage) -> None: 'remote': lmsg.pr_repo, 'ref': lmsg.pr_ref, 'sentdate': b4.LoreMessage.clean_header(lmsg.msg['Date']), - 'quote': b4.make_quote(lmsg.body, maxlines=6) + 'quote': b4.make_quote(lmsg.body, maxlines=6), } fullpath = os.path.join(datadir, filename) with open(fullpath, 'w', encoding='utf-8') as fh: @@ -266,9 +294,11 @@ def thanks_record_pr(lmsg: b4.LoreMessage) -> None: b4.patchwork_set_state([lmsg.msgid], pwstate) -def explode(gitdir: Optional[str], lmsg: b4.LoreMessage, - usefrom: Optional[str] = None) -> List[email.message.EmailMessage]: +def explode( + gitdir: Optional[str], lmsg: b4.LoreMessage, usefrom: Optional[str] = None +) -> List[email.message.EmailMessage]: import b4.ez + ecode = fetch_remote(gitdir, lmsg, check_sig=False, ty_track=False) if ecode > 0: raise RuntimeError('Fetching unsuccessful') @@ -313,22 +343,33 @@ def explode(gitdir: Optional[str], lmsg: b4.LoreMessage, config = b4.get_main_config() msgid_tpt = f'<b4-pr-%s-{lmsg.msgid}>' - pmsgs = b4.git_range_to_patches(gitdir, lmsg.pr_base_commit, 'FETCH_HEAD', - prefixes=prefixes, msgid_tpt=msgid_tpt, - seriests=int(lmsg.date.timestamp()), mailfrom=mailfrom) + pmsgs = b4.git_range_to_patches( + gitdir, + lmsg.pr_base_commit, + 'FETCH_HEAD', + prefixes=prefixes, + msgid_tpt=msgid_tpt, + seriests=int(lmsg.date.timestamp()), + mailfrom=mailfrom, + ) msgs = list() # Build the cover message from the pull request body linkmask = config.get('linkmask', 'https://lore.kernel.org/%s') assert isinstance(linkmask, str), 'linkmask must be a string' cbody = '%s\n\nbase-commit: %s\npull-request: %s\n' % ( - lmsg.body.strip(), lmsg.pr_base_commit, linkmask % lmsg.msgid) + lmsg.body.strip(), + lmsg.pr_base_commit, + linkmask % lmsg.msgid, + ) if len(pmsgs) == 1: b4.ez.mixin_cover(cbody, pmsgs) else: lmsg.lsubject.prefixes = prefixes - b4.ez.add_cover(lmsg.lsubject, msgid_tpt, pmsgs, cbody, int(lmsg.date.timestamp())) + b4.ez.add_cover( + lmsg.lsubject, msgid_tpt, pmsgs, cbody, int(lmsg.date.timestamp()) + ) for _at, (_commit, msg) in enumerate(pmsgs): msg.add_header('To', b4.format_addrs(allto)) @@ -336,7 +377,9 @@ def explode(gitdir: Optional[str], lmsg: b4.LoreMessage, msg.add_header('Cc', b4.format_addrs(allcc)) if lmsg.msg['List-Id']: - msg.add_header('X-Original-List-Id', b4.LoreMessage.clean_header(lmsg.msg['List-Id'])) + msg.add_header( + 'X-Original-List-Id', b4.LoreMessage.clean_header(lmsg.msg['List-Id']) + ) msgs.append(msg) logger.info(' %s', re.sub(r'\n\s*', ' ', msg.get('Subject', '(no subject)'))) @@ -394,7 +437,11 @@ def get_pr_from_github(ghurl: str) -> Optional[b4.LoreMessage]: 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').replace(tzinfo=timezone.utc)) + created_at = utils.format_datetime( + datetime.strptime(prdata.get('created_at'), '%Y-%m-%dT%H:%M:%SZ').replace( + tzinfo=timezone.utc + ) + ) msg['Date'] = created_at msg.set_charset('utf-8') body = prdata.get('body') @@ -416,12 +463,17 @@ def main(cmdargs: argparse.Namespace) -> None: if not cmdargs.no_stdin and not sys.stdin.isatty(): logger.debug('Getting PR message from stdin') - msg = email.parser.BytesParser(policy=b4.emlpolicy, - _class=email.message.EmailMessage).parse(sys.stdin.buffer) + msg = email.parser.BytesParser( + policy=b4.emlpolicy, _class=email.message.EmailMessage + ).parse(sys.stdin.buffer) 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: + 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: @@ -459,13 +511,24 @@ def main(cmdargs: argparse.Namespace) -> None: if msgs: if cmdargs.sendidentity: # Pass exploded series via git-send-email - config = b4.get_config_from_git(rf'sendemail\.{cmdargs.sendidentity}\..*') + 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) + 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] + gitargs = [ + 'send-email', + '--identity', + cmdargs.sendidentity, + '--from', + mailfrom, + ] if cmdargs.dryrun: gitargs.append('--dry-run') # Write out everything into a temporary dir @@ -477,7 +540,9 @@ def main(cmdargs: argparse.Namespace) -> None: tfh.write(msg.as_bytes(policy=b4.emlpolicy)) gitargs.append(outfile) counter += 1 - ecode, out = b4.git_run_command(cmdargs.gitdir, gitargs, logstderr=True) + ecode, out = b4.git_run_command( + cmdargs.gitdir, gitargs, logstderr=True + ) if cmdargs.dryrun: logger.info(out) sys.exit(ecode) @@ -521,7 +586,9 @@ def main(cmdargs: argparse.Namespace) -> None: sys.exit(1) # Is it at the tip of FETCH_HEAD? - loglines = b4.git_get_command_lines(gitdir, ['log', '-1', '--pretty=oneline', '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: diff --git a/src/b4/review/__init__.py b/src/b4/review/__init__.py index df1e39b..56e5df2 100644 --- a/src/b4/review/__init__.py +++ b/src/b4/review/__init__.py @@ -29,14 +29,23 @@ from b4.review._review import ( # Tell mypy these private symbols are intentionally re-exported __all__ = [ - '_retrieve_messages', 'retrieve_series_messages', '_get_lore_series', - '_collect_followups', '_collect_reply_headers', - '_get_my_review', '_ensure_my_review', '_cleanup_review', - '_get_patch_state', '_set_patch_state', + '_retrieve_messages', + 'retrieve_series_messages', + '_get_lore_series', + '_collect_followups', + '_collect_reply_headers', + '_get_my_review', + '_ensure_my_review', + '_cleanup_review', + '_get_patch_state', + '_set_patch_state', '_resolve_comment_positions', - '_render_quoted_diff_with_comments', '_extract_editor_comments', - '_clear_other_comments', '_strip_subject', - '_build_reply_from_comments', '_ensure_trailers_in_body', + '_render_quoted_diff_with_comments', + '_extract_editor_comments', + '_clear_other_comments', + '_strip_subject', + '_build_reply_from_comments', + '_ensure_trailers_in_body', '_build_review_email', '_integrate_agent_reviews', '_extract_comments_from_quoted_reply', diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py index 661369b..b061139 100644 --- a/src/b4/review/_review.py +++ b/src/b4/review/_review.py @@ -33,8 +33,7 @@ COMMIT_MESSAGE_PATH = ':message' _REPLY_CONTEXT_LINES = 5 -def _should_promote_waiting(newer_vers: List[int], - previously_known: Set[int]) -> bool: +def _should_promote_waiting(newer_vers: List[int], previously_known: Set[int]) -> bool: """Decide whether a waiting series should be promoted to reviewing. Only promotes when at least one of the newer versions was not @@ -60,8 +59,10 @@ def _strip_subject(text: str) -> List[str]: def make_review_magic_json(data: Dict[str, Any]) -> str: - mj = (f'{REVIEW_MAGIC_MARKER}\n' - '# This section is used internally by b4 review for tracking purposes.\n') + mj = ( + f'{REVIEW_MAGIC_MARKER}\n' + '# This section is used internally by b4 review for tracking purposes.\n' + ) return mj + json.dumps(data, indent=2) @@ -99,10 +100,14 @@ def _collect_reply_headers(lmsg: b4.LoreMessage) -> Dict[str, str]: allcc = [] logger.debug('Unable to parse the Cc: header in %s: %s', lmsg.msgid, str(ex)) try: - reply_to = email.utils.getaddresses([str(x) for x in lmsg.msg.get_all('reply-to', [])]) + reply_to = email.utils.getaddresses( + [str(x) for x in lmsg.msg.get_all('reply-to', [])] + ) except Exception as ex: reply_to = [] - logger.debug('Unable to parse the Reply-To: header in %s: %s', lmsg.msgid, str(ex)) + logger.debug( + 'Unable to parse the Reply-To: header in %s: %s', lmsg.msgid, str(ex) + ) headers: Dict[str, str] = { 'msgid': lmsg.msgid, @@ -148,7 +153,9 @@ def check_series_attestation(lser: b4.LoreSeries) -> Optional[str]: for lmsg in lser.patches[1:]: if lmsg is None: continue - attestations, _passing, _critical = lmsg.get_attestation_status(attpolicy, maxdays) + attestations, _passing, _critical = lmsg.get_attestation_status( + attpolicy, maxdays + ) for att in attestations: key = (att.get('status', ''), att.get('identity', '')) seen.add(key) @@ -178,8 +185,9 @@ def _retrieve_messages(message_id: str) -> List[email.message.EmailMessage]: return msgs -def retrieve_series_messages(series: Dict[str, Any], - identifier: str) -> List[email.message.EmailMessage]: +def retrieve_series_messages( + series: Dict[str, Any], identifier: str +) -> List[email.message.EmailMessage]: """Fetch messages for a tracked series, using stored patch info when available. For rethreaded series, reads the series_patches table to fetch each @@ -202,7 +210,9 @@ def retrieve_series_messages(series: Dict[str, Any], _msgids, all_msgs = b4.fetch_rethread_messages(msgids, nocache=True) _cover_msgid, msgs = b4.LoreSeries.rethread_series(msgids, all_msgs) if not msgs: - raise LookupError(f'Could not retrieve series patches for {change_id}') + raise LookupError( + f'Could not retrieve series patches for {change_id}' + ) return msgs if not message_id: @@ -210,8 +220,11 @@ def retrieve_series_messages(series: Dict[str, Any], return _retrieve_messages(message_id) -def _get_lore_series(msgs: List[email.message.EmailMessage], sloppytrailers: bool = False, - wantver: Optional[int] = None) -> 'b4.LoreSeries': +def _get_lore_series( + msgs: List[email.message.EmailMessage], + sloppytrailers: bool = False, + wantver: Optional[int] = None, +) -> 'b4.LoreSeries': """Build a LoreMailbox from messages and return the requested series version. When *wantver* is ``None`` (the default), the highest version found @@ -229,10 +242,11 @@ def _get_lore_series(msgs: List[email.message.EmailMessage], sloppytrailers: boo if wantver not in lmbx.series: found = ', '.join(f'v{v}' for v in sorted(lmbx.series.keys())) raise LookupError( - f'Series version {wantver} not found in retrieved messages' - f' (found: {found})') - lser = lmbx.get_series(wantver, sloppytrailers=sloppytrailers, - codereview_trailers=False) + f'Series version {wantver} not found in retrieved messages (found: {found})' + ) + lser = lmbx.get_series( + wantver, sloppytrailers=sloppytrailers, codereview_trailers=False + ) if not lser: raise LookupError(f'Could not find series version {wantver}') return lser @@ -253,12 +267,18 @@ def get_reference_message(lser: 'b4.LoreSeries') -> 'b4.LoreMessage': return ref_msg -def create_review_branch(topdir: str, branch_name: str, base_commit: str, - lser: b4.LoreSeries, linkurl: str, linkmask: str, - num_prereqs: int = 0, - identifier: Optional[str] = None, - status: str = 'reviewing', - is_rethreaded: bool = False) -> None: +def create_review_branch( + topdir: str, + branch_name: str, + base_commit: str, + lser: b4.LoreSeries, + linkurl: str, + linkmask: str, + num_prereqs: int = 0, + identifier: Optional[str] = None, + status: str = 'reviewing', + is_rethreaded: bool = False, +) -> None: # Verify branch does not already exist ecode, out = b4.git_run_command(topdir, ['rev-parse', '--verify', branch_name]) if ecode == 0: @@ -272,23 +292,27 @@ def create_review_branch(topdir: str, branch_name: str, base_commit: str, current_branch = out.strip() # Resolve base_commit to a concrete hash before checkout changes HEAD - ecode, out = b4.git_run_command(topdir, ['rev-parse', f'{base_commit}^{{}}'], logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['rev-parse', f'{base_commit}^{{}}'], logstderr=True + ) if ecode > 0: logger.critical('Unable to resolve base commit %s', base_commit) sys.exit(1) resolved_base = out.strip() # Create and check out the review branch - ecode, out = b4.git_run_command(topdir, ['checkout', '-b', branch_name, resolved_base], - logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['checkout', '-b', branch_name, resolved_base], logstderr=True + ) if ecode > 0: logger.critical('Unable to create branch %s at %s', branch_name, resolved_base) logger.critical(out.strip()) sys.exit(1) # Cherry-pick the applied patches from FETCH_HEAD - ecode, out = b4.git_run_command(topdir, ['cherry-pick', f'{resolved_base}..FETCH_HEAD'], - logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['cherry-pick', f'{resolved_base}..FETCH_HEAD'], logstderr=True + ) if ecode > 0: logger.critical('Unable to cherry-pick patches onto review branch') logger.critical(out.strip()) @@ -301,8 +325,9 @@ def create_review_branch(topdir: str, branch_name: str, base_commit: str, sys.exit(1) # Record the first patch commit (the one right after base) - ecode, out = b4.git_run_command(topdir, ['rev-list', '--reverse', - f'{resolved_base}..HEAD'], logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['rev-list', '--reverse', f'{resolved_base}..HEAD'], logstderr=True + ) if ecode > 0 or not out.strip(): logger.critical('Unable to determine first patch commit') sys.exit(1) @@ -317,8 +342,9 @@ def create_review_branch(topdir: str, branch_name: str, base_commit: str, cover_content = clmsg.subject + '\n\n' + clmsg.body elif lser.patches[1] is not None: clmsg = lser.patches[1] - cover_content = (clmsg.subject + '\n\n' - 'NOTE: No cover letter provided by the author.') + cover_content = ( + clmsg.subject + '\n\nNOTE: No cover letter provided by the author.' + ) else: cover_content = 'NOTE: No cover letter or first patch available.' @@ -341,7 +367,7 @@ def create_review_branch(topdir: str, branch_name: str, base_commit: str, if pbasement.strip(): # Keep only the notes before the diff (diffstat, changelog, etc.) diff_start = b4.DIFF_RE.search(pbasement) - notes = pbasement[:diff_start.start()] if diff_start else pbasement + notes = pbasement[: diff_start.start()] if diff_start else pbasement if notes.strip(): pmeta['basement'] = notes patches_meta.append(pmeta) @@ -353,7 +379,8 @@ def create_review_branch(topdir: str, branch_name: str, base_commit: str, 'identifier': identifier, 'status': status, 'revision': lser.revision, - 'change-id': lser.change_id or branch_name.removeprefix(REVIEW_BRANCH_PREFIX), + 'change-id': lser.change_id + or branch_name.removeprefix(REVIEW_BRANCH_PREFIX), 'link': linkurl, 'subject': clmsg.full_subject if clmsg else '', 'fromname': lser.fromname or '', @@ -374,8 +401,12 @@ def create_review_branch(topdir: str, branch_name: str, base_commit: str, # Create the tracking commit at the tip of the branch commit_msg = cover_content + '\n\n' + make_review_magic_json(tracking) - ecode, out = b4.git_run_command(topdir, ['commit', '--allow-empty', '-F', '-'], - stdin=commit_msg.encode(), logstderr=True) + ecode, out = b4.git_run_command( + topdir, + ['commit', '--allow-empty', '-F', '-'], + stdin=commit_msg.encode(), + logstderr=True, + ) if ecode > 0: logger.critical('Unable to create tracking commit') logger.critical(out.strip()) @@ -388,6 +419,7 @@ def create_review_branch(topdir: str, branch_name: str, base_commit: str, # Mark cover + patch messages as Seen in the messages DB try: from b4.review import messages + entries = [] for pmsg in lser.patches: if pmsg is None or not pmsg.msgid: @@ -419,7 +451,9 @@ def main(cmdargs: argparse.Namespace) -> None: cmd_show_info(cmdargs) -def get_review_branch_patch_ids(topdir: str, branch: str) -> List[Tuple[int, str, Optional[str]]]: +def get_review_branch_patch_ids( + topdir: str, branch: str +) -> List[Tuple[int, str, Optional[str]]]: """Compute stable patch-ids for every patch commit on a review branch. Loads tracking data to find the first-patch-commit, then iterates @@ -435,7 +469,8 @@ def get_review_branch_patch_ids(topdir: str, branch: str) -> List[Tuple[int, str return [] ecode, out = b4.git_run_command( - topdir, ['rev-list', '--reverse', f'{first_patch}~1..{branch}~1']) + topdir, ['rev-list', '--reverse', f'{first_patch}~1..{branch}~1'] + ) if ecode > 0 or not out.strip(): return [] @@ -450,7 +485,8 @@ def get_review_branch_patch_ids(topdir: str, branch: str) -> List[Tuple[int, str result.append((idx, sha, None)) continue ecode, pid_out = b4.git_run_command( - topdir, ['patch-id', '--stable'], stdin=bpatch) + topdir, ['patch-id', '--stable'], stdin=bpatch + ) if ecode > 0 or not pid_out.strip(): result.append((idx, sha, None)) continue @@ -471,7 +507,9 @@ def load_tracking(topdir: str, branch: str) -> Tuple[str, Dict[str, Any]]: commit_msg = out.strip() if REVIEW_MAGIC_MARKER not in commit_msg: - logger.critical('Branch %s does not contain a valid review tracking commit', branch) + logger.critical( + 'Branch %s does not contain a valid review tracking commit', branch + ) sys.exit(1) parts = commit_msg.split(REVIEW_MAGIC_MARKER, maxsplit=1) @@ -499,7 +537,7 @@ def get_review_info(topdir: str, branch: str) -> Dict[str, Union[str, int, bool, sender = '' if series.get('fromname') or series.get('fromemail'): - sender = f"{series.get('fromname', '')} <{series.get('fromemail', '')}>" + sender = f'{series.get("fromname", "")} <{series.get("fromemail", "")}>' first_patch = series.get('first-patch-commit') prereqs = series.get('prerequisite-commits', []) @@ -531,9 +569,15 @@ def get_review_info(topdir: str, branch: str) -> Dict[str, Union[str, int, bool, if first_patch: # Range: first-patch-commit~1..branch~1 (excludes the tracking commit at tip) commit_range = f'{first_patch}~1..{branch}~1' - lines = b4.git_get_command_lines(topdir, [ - 'log', '--reverse', '--format=%h %s', commit_range, - ]) + lines = b4.git_get_command_lines( + topdir, + [ + 'log', + '--reverse', + '--format=%h %s', + commit_range, + ], + ) info['series-range'] = f'{first_patch}..{branch}~1' info['num-patches'] = len(lines) for line in lines: @@ -579,8 +623,11 @@ def show_review_info(param: str, as_json: bool = False) -> None: sys.exit(1) if not mybranch.startswith(REVIEW_BRANCH_PREFIX): - logger.critical('Branch %s does not look like a review branch (expected prefix %s)', - mybranch, REVIEW_BRANCH_PREFIX) + logger.critical( + 'Branch %s does not look like a review branch (expected prefix %s)', + mybranch, + REVIEW_BRANCH_PREFIX, + ) sys.exit(1) info = get_review_info(topdir, mybranch) @@ -627,8 +674,16 @@ def list_review_branches(as_json: bool = False) -> None: for idx, info in enumerate(all_info): if idx > 0: print() - for key in ('branch', 'change-id', 'status', 'subject', 'sender', - 'revision', 'num-patches', 'complete'): + for key in ( + 'branch', + 'change-id', + 'status', + 'subject', + 'sender', + 'revision', + 'num-patches', + 'complete', + ): val = info.get(key) if val is not None: print(f'{key}: {val}') @@ -642,8 +697,9 @@ def cmd_show_info(cmdargs: argparse.Namespace) -> None: show_review_info(cmdargs.param, as_json=cmdargs.json_output) -def save_tracking_ref(topdir: str, branch: str, - cover_text: str, tracking: Dict[str, Any]) -> bool: +def save_tracking_ref( + topdir: str, branch: str, cover_text: str, tracking: Dict[str, Any] +) -> bool: """Amend the tracking commit at the tip of a ref without checkout. Uses git commit-tree + git update-ref so that commit.gpgsign and @@ -651,7 +707,9 @@ def save_tracking_ref(topdir: str, branch: str, not benefit from signing. Returns True on success. """ if not branch.startswith(REVIEW_BRANCH_PREFIX): - logger.critical('Refusing to write tracking commit to non-review branch: %s', branch) + logger.critical( + 'Refusing to write tracking commit to non-review branch: %s', branch + ) return False commit_msg = cover_text + '\n\n' + make_review_magic_json(tracking) ecode, out = b4.git_run_command(topdir, ['rev-parse', f'{branch}^{{tree}}']) @@ -662,14 +720,17 @@ def save_tracking_ref(topdir: str, branch: str, if ecode > 0: return False parent = out.strip() - ecode, out = b4.git_run_command(topdir, - ['commit-tree', tree, '-p', parent, '-F', '-'], - stdin=commit_msg.encode()) + ecode, out = b4.git_run_command( + topdir, + ['commit-tree', tree, '-p', parent, '-F', '-'], + stdin=commit_msg.encode(), + ) if ecode > 0: return False new_sha = out.strip() - ecode, out = b4.git_run_command(topdir, - ['update-ref', f'refs/heads/{branch}', new_sha]) + ecode, out = b4.git_run_command( + topdir, ['update-ref', f'refs/heads/{branch}', new_sha] + ) return ecode == 0 @@ -706,7 +767,9 @@ def _get_my_review(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> Dict[str, return result -def _ensure_my_review(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> Dict[str, Any]: +def _ensure_my_review( + target: Dict[str, Any], usercfg: b4.ConfigDictT +) -> Dict[str, Any]: """Return the current user's review sub-dict, creating it if needed.""" email = str(usercfg.get('email', '[email protected]')) name = str(usercfg.get('name', 'Unknown')) @@ -750,8 +813,7 @@ def _get_patch_state(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> str: if explicit in ('skip', 'done'): return explicit trailer_keys = { - t.split(':', 1)[0].strip().lower() - for t in review.get('trailers', []) + t.split(':', 1)[0].strip().lower() for t in review.get('trailers', []) } if _NACK_TRAILER_KEY in trailer_keys: return 'draft' @@ -762,14 +824,16 @@ def _get_patch_state(target: Dict[str, Any], usercfg: b4.ConfigDictT) -> str: # Check for external reviewer comments my_email = str(usercfg.get('email', '')) all_reviews = target.get('reviews', {}) - if any(addr != my_email and rev.get('comments') - for addr, rev in all_reviews.items()): + if any( + addr != my_email and rev.get('comments') for addr, rev in all_reviews.items() + ): return 'external' return explicit if explicit else '' -def _set_patch_state(target: Dict[str, Any], usercfg: b4.ConfigDictT, - state: str) -> None: +def _set_patch_state( + target: Dict[str, Any], usercfg: b4.ConfigDictT, state: str +) -> None: """Store an explicit patch state ('done', 'skip', 'unchanged', or '' to clear).""" if state: review = _ensure_my_review(target, usercfg) @@ -866,8 +930,9 @@ def _resolve_comment_positions( # comment's current (source-derived) position and path. cur_path = c['path'] cur_line = c['line'] - best = min(positions, - key=lambda p: (p[0] != cur_path, abs(p[1] - cur_line))) + best = min( + positions, key=lambda p: (p[0] != cur_path, abs(p[1] - cur_line)) + ) c['path'], c['line'] = best @@ -894,8 +959,7 @@ def reanchor_patch_comments( if not comments or not any(c.get('content') for c in comments): continue if real_diff is None: - ecode, real_diff = b4.git_run_command( - topdir, ['diff', f'{sha}~1', sha]) + ecode, real_diff = b4.git_run_command(topdir, ['diff', f'{sha}~1', sha]) if ecode != 0: break _resolve_comment_positions(real_diff, comments) @@ -999,8 +1063,9 @@ def _integrate_agent_reviews( # Read NNNN.txt files (per-patch reviews, 1-indexed) try: - entries = sorted(f for f in os.listdir(review_dir) - if re.match(r'^\d{4}\.txt$', f)) + entries = sorted( + f for f in os.listdir(review_dir) if re.match(r'^\d{4}\.txt$', f) + ) except OSError: entries = [] @@ -1008,12 +1073,19 @@ def _integrate_agent_reviews( patch_num = int(fname[:4]) # 1-indexed idx = patch_num - 1 if idx < 0 or idx >= len(patches): - logger.warning('b4-review/%s/%s: patch number out of range, skipping', - head_sha[:12], fname) + logger.warning( + 'b4-review/%s/%s: patch number out of range, skipping', + head_sha[:12], + fname, + ) continue if idx >= len(commit_shas): - logger.warning('b4-review/%s/%s: no commit SHA for patch %d, skipping', - head_sha[:12], fname, patch_num) + logger.warning( + 'b4-review/%s/%s: no commit SHA for patch %d, skipping', + head_sha[:12], + fname, + patch_num, + ) continue fpath = os.path.join(review_dir, fname) @@ -1031,7 +1103,7 @@ def _integrate_agent_reviews( diff_portion = file_text elif diff_idx >= 0: note_text = file_text[:diff_idx].strip() - diff_portion = file_text[diff_idx + 1:] + diff_portion = file_text[diff_idx + 1 :] else: note_text = file_text.strip() @@ -1075,8 +1147,11 @@ def _integrate_agent_reviews( save_tracking_ref(topdir, branch, cover_text, tracking) else: save_tracking(topdir, cover_text, tracking) - logger.info('Integrated agent review data from %d file(s) in b4-review/%s', - integrated, head_sha[:12]) + logger.info( + 'Integrated agent review data from %d file(s) in b4-review/%s', + integrated, + head_sha[:12], + ) # Clean up the consumed review directory shutil.rmtree(review_dir, ignore_errors=True) @@ -1084,8 +1159,9 @@ def _integrate_agent_reviews( return True -def _extract_comments_from_quoted_reply(text: str, - capture_preamble: bool = False) -> List[Dict[str, Any]]: +def _extract_comments_from_quoted_reply( + text: str, capture_preamble: bool = False +) -> List[Dict[str, Any]]: """Extract inline comments from a ``> ``-quoted email reply. This is the standard mailing-list code review format: the reviewer @@ -1111,9 +1187,18 @@ def _extract_comments_from_quoted_reply(text: str, i = 0 while i < len(raw_lines): line = raw_lines[i] - stripped = line[2:] if line.startswith('> ') else line[1:] if line.startswith('>') else None - if (stripped is not None - and stripped.startswith('diff --git a/') and ' b/' not in stripped): + stripped = ( + line[2:] + if line.startswith('> ') + else line[1:] + if line.startswith('>') + else None + ) + if ( + stripped is not None + and stripped.startswith('diff --git a/') + and ' b/' not in stripped + ): # Peek at next line for the b/ continuation if i + 1 < len(raw_lines): nxt = raw_lines[i + 1] @@ -1170,11 +1255,13 @@ def _extract_comments_from_quoted_reply(text: str, """Store preamble text as a comment on commit message line 0.""" text = '\n'.join(preamble_lines).strip() if text: - comments.append({ - 'path': COMMIT_MESSAGE_PATH, - 'line': 0, - 'text': text, - }) + comments.append( + { + 'path': COMMIT_MESSAGE_PATH, + 'line': 0, + 'text': text, + } + ) preamble_lines.clear() for line in text.splitlines(): @@ -1330,6 +1417,7 @@ def _integrate_sashiko_reviews( return False from b4.review.checks import _fetch_sashiko_patchset, clear_sashiko_cache + clear_sashiko_cache() patchset = _fetch_sashiko_patchset(series_msgid, sashiko_url) if not patchset: @@ -1444,8 +1532,8 @@ def _integrate_followup_inline_comments( cover_msgid = series.get('header-info', {}).get('msgid', '') followup_comments = b4.review.tracking._parse_msgs_to_followup_comments( - liblore.utils.split_mbox(mbox_bytes), - cover_msgid, patches) + liblore.utils.split_mbox(mbox_bytes), cover_msgid, patches + ) integrated = 0 @@ -1633,8 +1721,9 @@ def _render_quoted_diff_with_comments( return '\n'.join(result) + '\n' -def _extract_editor_comments(edited_text: str, - diff_text: str = '') -> List[Dict[str, Any]]: +def _extract_editor_comments( + edited_text: str, diff_text: str = '' +) -> List[Dict[str, Any]]: """Extract comments from the quoted-diff editor format. Strips instruction lines (``#`` prefix) and external reviewer @@ -1655,16 +1744,19 @@ def _extract_editor_comments(edited_text: str, continue filtered.append(line) comments = _extract_comments_from_quoted_reply( - '\n'.join(filtered), capture_preamble=True) + '\n'.join(filtered), capture_preamble=True + ) if diff_text and comments: _resolve_comment_positions(diff_text, comments) return comments -def _build_reply_from_comments(diff_text: str, - comments: List[Dict[str, Any]], - review_trailers: List[str], - commit_msg: Optional[str] = None) -> str: +def _build_reply_from_comments( + diff_text: str, + comments: List[Dict[str, Any]], + review_trailers: List[str], + commit_msg: Optional[str] = None, +) -> str: """Build an email reply body from review comments. For each hunk that has comments, quotes the hunk up to the commented @@ -1718,8 +1810,9 @@ def _build_reply_from_comments(diff_text: str, if msg_comment_indices: prev_quoted = 0 # last msg line index (1-based) emitted for comment_lineno in msg_comment_indices: - window_start = max(prev_quoted + 1, - comment_lineno - _REPLY_CONTEXT_LINES) + window_start = max( + prev_quoted + 1, comment_lineno - _REPLY_CONTEXT_LINES + ) # Clamp to valid msg_lines range window_start = min(window_start, len(msg_lines) + 1) comment_quote_end = min(comment_lineno, len(msg_lines)) @@ -1913,16 +2006,19 @@ def update_series_tracking( if b4.can_network: try: _conn = b4.review.tracking.get_db(identifier) - _known = set(r['revision'] for r in b4.review.tracking.get_revisions(_conn, change_id)) + _known = set( + r['revision'] + for r in b4.review.tracking.get_revisions(_conn, change_id) + ) _conn.close() except Exception: _known = set() msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True) if current_rev > 1 and not _known: - msgs = b4.mbox.get_extra_series(msgs, direction=-1, - wantvers=list(range(1, current_rev)), - nocache=True) + msgs = b4.mbox.get_extra_series( + msgs, direction=-1, wantvers=list(range(1, current_rev)), nocache=True + ) lmbx = b4.LoreMailbox() for msg in msgs: @@ -1935,15 +2031,16 @@ def update_series_tracking( lser_att = lmbx.get_series(sloppytrailers=False) if lser_att is not None: att = check_series_attestation(lser_att) - b4.review.tracking.update_attestation( - identifier, change_id, current_rev, att) + b4.review.tracking.update_attestation(identifier, change_id, current_rev, att) # Record all discovered revisions in SQLite, keeping track of what # was already known so we can distinguish genuinely new versions. previously_known: Set[int] = set() try: conn = b4.review.tracking.get_db(identifier) - previously_known = set(r['revision'] for r in b4.review.tracking.get_revisions(conn, change_id)) + previously_known = set( + r['revision'] for r in b4.review.tracking.get_revisions(conn, change_id) + ) for v in sorted(lmbx.series.keys()): v_ser = lmbx.series[v] v_msgid = '' @@ -1952,10 +2049,14 @@ def update_series_tracking( for p in v_ser.patches: if p is not None: v_msgid = p.msgid - v_subject = getattr(p, 'full_subject', '') or getattr(p, 'subject', '') + v_subject = getattr(p, 'full_subject', '') or getattr( + p, 'subject', '' + ) break v_link = (linkmask % v_msgid) if v_msgid and '%s' in str(linkmask) else '' - b4.review.tracking.add_revision(conn, change_id, v, v_msgid, v_subject, v_link) + b4.review.tracking.add_revision( + conn, change_id, v, v_msgid, v_subject, v_link + ) if v not in previously_known: result['new_revisions'] += 1 conn.close() @@ -1969,17 +2070,16 @@ def update_series_tracking( # prevents a broken version (e.g. v2 that fails to apply) from # repeatedly waking the series after the maintainer puts it back # into waiting. - if status == 'waiting' and _should_promote_waiting( - newer_vers, previously_known): - try: - conn = b4.review.tracking.get_db(identifier) - b4.review.tracking.update_series_status( - conn, change_id, 'reviewing', - revision=series.get('revision')) - conn.close() - result['promoted'] = True - except Exception as ex: - logger.warning('Could not promote waiting series: %s', ex) + if status == 'waiting' and _should_promote_waiting(newer_vers, previously_known): + try: + conn = b4.review.tracking.get_db(identifier) + b4.review.tracking.update_series_status( + conn, change_id, 'reviewing', revision=series.get('revision') + ) + conn.close() + result['promoted'] = True + except Exception as ex: + logger.warning('Could not promote waiting series: %s', ex) # Update follow-up trailers if the series has a review branch if status in ('reviewing', 'replied', 'waiting') and topdir: @@ -2000,14 +2100,15 @@ def update_series_tracking( else: t_series.pop('newer-versions', None) - lser = lmbx.get_series(wantver, sloppytrailers=False, - codereview_trailers=True) + lser = lmbx.get_series(wantver, sloppytrailers=False, codereview_trailers=True) if lser is None: result['error'] = f'Could not find series v{wantver} in retrieved messages' return result # Collect fresh cover followups - clmsg = lser.patches[0] if lser.has_cover and lser.patches[0] is not None else None + clmsg = ( + lser.patches[0] if lser.has_cover and lser.patches[0] is not None else None + ) new_cover_followups = _collect_followups(clmsg, linkmask) if clmsg else list() # Collect fresh per-patch followups @@ -2059,7 +2160,8 @@ def update_series_tracking( try: conn = b4.review.tracking.get_db(identifier) b4.review.tracking.update_message_count_from_msgs( - conn, change_id, current_rev, thread_msgs, topdir=topdir) + conn, change_id, current_rev, thread_msgs, topdir=topdir + ) conn.close() result['counts_updated'] = True except Exception as ex: @@ -2095,9 +2197,12 @@ def cmd_tui(cmdargs: argparse.Namespace) -> None: logger.critical('Enroll with: b4 review enroll') sys.exit(1) - b4.review_tui.run_tracking_tui(identifier, email_dryrun=cmdargs.email_dryrun, - no_sign=cmdargs.no_sign, - no_mouse=cmdargs.no_mouse) + b4.review_tui.run_tracking_tui( + identifier, + email_dryrun=cmdargs.email_dryrun, + no_sign=cmdargs.no_sign, + no_mouse=cmdargs.no_mouse, + ) def _prepare_review_session(cmdargs: argparse.Namespace) -> Dict[str, Any]: @@ -2121,8 +2226,11 @@ def _prepare_review_session(cmdargs: argparse.Namespace) -> Dict[str, Any]: branch = out.strip() if not branch.startswith(REVIEW_BRANCH_PREFIX): - logger.critical('Branch %s does not look like a review branch (expected prefix %s)', - branch, REVIEW_BRANCH_PREFIX) + logger.critical( + 'Branch %s does not look like a review branch (expected prefix %s)', + branch, + REVIEW_BRANCH_PREFIX, + ) sys.exit(1) # No checkout needed — all git operations use explicit refs. @@ -2150,8 +2258,9 @@ def _prepare_review_session(cmdargs: argparse.Namespace) -> Dict[str, Any]: commit_shas = out.strip().splitlines() # Get commit subjects - ecode, out = b4.git_run_command(topdir, ['log', '--reverse', '--format=%s', - range_spec]) + ecode, out = b4.git_run_command( + topdir, ['log', '--reverse', '--format=%s', range_spec] + ) if ecode > 0: logger.critical('Unable to get commit subjects') sys.exit(1) @@ -2167,7 +2276,9 @@ def _prepare_review_session(cmdargs: argparse.Namespace) -> Dict[str, Any]: cover_subject = series.get('subject', '') cover_subject_clean = b4.LoreSubject(cover_subject).subject if not cover_subject_clean: - cover_subject_clean = cover_text.split('\n', maxsplit=1)[0] if cover_text else '(no subject)' + cover_subject_clean = ( + cover_text.split('\n', maxsplit=1)[0] if cover_text else '(no subject)' + ) # Get user identity for trailers (needed throughout the loop) usercfg = b4.get_user_config() @@ -2176,20 +2287,32 @@ def _prepare_review_session(cmdargs: argparse.Namespace) -> Dict[str, Any]: default_identity = f'{user_name} <{user_email}>' # Integrate agent reviews from .git/b4-review/ - _integrate_agent_reviews(topdir, cover_text, tracking, commit_shas, patches, branch=branch) + _integrate_agent_reviews( + topdir, cover_text, tracking, commit_shas, patches, branch=branch + ) # Integrate sashiko inline reviews (if configured) - _integrate_sashiko_reviews(topdir, cover_text, tracking, commit_shas, patches, branch=branch) + _integrate_sashiko_reviews( + topdir, cover_text, tracking, commit_shas, patches, branch=branch + ) # Integrate inline comments from mailing-list follow-up messages - _integrate_followup_inline_comments(topdir, cover_text, tracking, commit_shas, patches, branch=branch) + _integrate_followup_inline_comments( + topdir, cover_text, tracking, commit_shas, patches, branch=branch + ) # Ensure the plain-text thread-context-blob exists for the AI agent. # Runs only when thread-blob was stored before this feature existed # (migration) or is being seen for the first time this session. change_id = series.get('change-id') - if change_id and series.get('thread-blob') and not series.get('thread-context-blob'): - b4.review.tracking.ensure_thread_context_blob(topdir, change_id, series, patches) + if ( + change_id + and series.get('thread-blob') + and not series.get('thread-context-blob') + ): + b4.review.tracking.ensure_thread_context_blob( + topdir, change_id, series, patches + ) # Record current branch so ReviewApp can restore it if it checks # out the review branch for shell/agent operations. @@ -2239,9 +2362,14 @@ def _ensure_trailers_in_body(body: str, trailers: List[str]) -> str: return main_body -def _build_review_email(series: Dict[str, Any], patch_meta: Optional[Dict[str, Any]], - review: Dict[str, Any], cover_text: str, - topdir: str, commit_sha: Optional[str]) -> Optional[email.message.EmailMessage]: +def _build_review_email( + series: Dict[str, Any], + patch_meta: Optional[Dict[str, Any]], + review: Dict[str, Any], + cover_text: str, + topdir: str, + commit_sha: Optional[str], +) -> Optional[email.message.EmailMessage]: """Build an EmailMessage for a single review entry (cover or patch). Returns None if there is nothing to send. @@ -2276,41 +2404,68 @@ def _build_review_email(series: Dict[str, Any], patch_meta: Optional[Dict[str, A elif comments and patch_meta is None: # Cover letter with structured comments — build reply from cover text reply_body = _build_reply_from_comments( - '', comments, trailers, commit_msg=cover_text) + '', comments, trailers, commit_msg=cover_text + ) # Add blank line before preamble, but not before quoted content sep = '\n\n' if not reply_body.startswith('>') else '\n' body = attribution + sep + reply_body elif comments and commit_sha and topdir: # Auto-generate reply from inline review comments ecode, commit_msg = b4.git_run_command( - topdir, ['show', '--format=%B', '--no-patch', commit_sha]) + topdir, ['show', '--format=%B', '--no-patch', commit_sha] + ) if ecode > 0: logger.warning('Could not get commit message for %s', commit_sha) return None ecode, diff_text = b4.git_run_command( - topdir, ['diff', f'{commit_sha}~1', commit_sha]) + topdir, ['diff', f'{commit_sha}~1', commit_sha] + ) if ecode > 0: logger.warning('Could not get diff for %s', commit_sha) return None reply_body = _build_reply_from_comments( - diff_text, comments, trailers, commit_msg=commit_msg) + diff_text, comments, trailers, commit_msg=commit_msg + ) sep = '\n\n' if not reply_body.startswith('>') else '\n' body = attribution + sep + reply_body else: # Trailer-only reply: quote the first paragraph of the original if patch_meta is not None and commit_sha and topdir: ecode, commit_msg = b4.git_run_command( - topdir, ['show', '--format=%B', '--no-patch', commit_sha]) + topdir, ['show', '--format=%B', '--no-patch', commit_sha] + ) if ecode == 0 and commit_msg.strip(): # Strip the subject line (already in Subject: Re: header) cm_body = '\n'.join(_strip_subject(commit_msg)) - body = attribution + '\n' + b4.make_quote(cm_body) + '\n\n' + '\n'.join(trailers) \ - if cm_body else \ - attribution + '\n' + b4.make_quote(cover_text) + '\n\n' + '\n'.join(trailers) + body = ( + attribution + + '\n' + + b4.make_quote(cm_body) + + '\n\n' + + '\n'.join(trailers) + if cm_body + else attribution + + '\n' + + b4.make_quote(cover_text) + + '\n\n' + + '\n'.join(trailers) + ) else: - body = attribution + '\n' + b4.make_quote(cover_text) + '\n\n' + '\n'.join(trailers) + body = ( + attribution + + '\n' + + b4.make_quote(cover_text) + + '\n\n' + + '\n'.join(trailers) + ) else: - body = attribution + '\n' + b4.make_quote(cover_text) + '\n\n' + '\n'.join(trailers) + body = ( + attribution + + '\n' + + b4.make_quote(cover_text) + + '\n\n' + + '\n'.join(trailers) + ) # Ensure all trailers appear in the body body = _ensure_trailers_in_body(body, trailers) @@ -2388,11 +2543,12 @@ def collect_review_emails( # Cover letter review (maintainer only) cover_review = series.get('reviews', {}).get(my_email, {}) - if (cover_review - and cover_review.get('patch-state') != 'skip' - and cover_review.get('sent-revision') is None): - msg = _build_review_email(series, None, cover_review, cover_text, - topdir, None) + if ( + cover_review + and cover_review.get('patch-state') != 'skip' + and cover_review.get('sent-revision') is None + ): + msg = _build_review_email(series, None, cover_review, cover_text, topdir, None) if msg is not None: msgs.append(msg) @@ -2404,8 +2560,9 @@ def collect_review_emails( if patch_review.get('sent-revision') is not None: continue commit_sha = commit_shas[idx] if idx < len(commit_shas) else None - msg = _build_review_email(series, patch_meta, patch_review, cover_text, - topdir, commit_sha) + msg = _build_review_email( + series, patch_meta, patch_review, cover_text, topdir, commit_sha + ) if msg is not None: msgs.append(msg) @@ -2455,7 +2612,9 @@ def pw_fetch_series(pwkey: str, pwurl: str, pwproj: str) -> List[Dict[str, Any]] series_map[sid] = { 'id': sid, 'name': s.get('name', '(no subject)'), - 'submitter': submitter.get('name', submitter.get('email', 'Unknown')), + 'submitter': submitter.get( + 'name', submitter.get('email', 'Unknown') + ), 'submitter_email': submitter.get('email', ''), 'delegate': delegate.get('username', ''), 'date': patch.get('date', ''), @@ -2477,7 +2636,9 @@ def pw_fetch_series(pwkey: str, pwurl: str, pwproj: str) -> List[Dict[str, Any]] # Aggregate CI check: worst status wins patch_check = patch.get('check', 'pending') cur_check = series_map[sid].get('check', 'pending') - if _check_priority.get(patch_check, 0) > _check_priority.get(cur_check, 0): + if _check_priority.get(patch_check, 0) > _check_priority.get( + cur_check, 0 + ): series_map[sid]['check'] = patch_check if patch_id: series_map[sid]['patch_ids'].append(patch_id) @@ -2510,8 +2671,9 @@ def pw_fetch_states(pwkey: str, pwurl: str, pwproj: str) -> List[Dict[str, Any]] return [{'slug': s, 'name': s.replace('-', ' ').title()} for s in default_slugs] -def pw_fetch_checks(pwkey: str, pwurl: str, - patch_ids: List[int]) -> List[Dict[str, Any]]: +def pw_fetch_checks( + pwkey: str, pwurl: str, patch_ids: List[int] +) -> List[Dict[str, Any]]: """Fetch CI check details for a list of patch IDs. Returns a flat list of check dicts, each augmented with 'patch_id'. @@ -2532,8 +2694,9 @@ def pw_fetch_checks(pwkey: str, pwurl: str, return all_checks -def pw_set_series_state(pwkey: str, pwurl: str, patch_ids: List[int], - state: str, archived: bool) -> Tuple[int, int]: +def pw_set_series_state( + pwkey: str, pwurl: str, patch_ids: List[int], state: str, archived: bool +) -> Tuple[int, int]: """Set state and archived flag on patches by patch ID. Returns (success_count, failure_count). @@ -2558,7 +2721,9 @@ def pw_set_series_state(pwkey: str, pwurl: str, patch_ids: List[int], return ok, fail -def pw_update_series_state(pw_series_id: int, state: str, archived: bool = False) -> bool: +def pw_update_series_state( + pw_series_id: int, state: str, archived: bool = False +) -> bool: """Update Patchwork state for a series tracked by pw_series_id. Looks up pw-key and pw-url from git config, fetches the patch IDs diff --git a/src/b4/review/checks.py b/src/b4/review/checks.py index 2ea5027..a7e1ff0 100644 --- a/src/b4/review/checks.py +++ b/src/b4/review/checks.py @@ -33,9 +33,10 @@ def clear_sashiko_cache() -> None: """Clear the sashiko patchset cache between check runs.""" _sashiko_patchset_cache.clear() + SCHEMA_VERSION = 1 -SCHEMA_SQL = ''' +SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY ); @@ -50,13 +51,14 @@ CREATE TABLE IF NOT EXISTS check_results ( checked_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now')), PRIMARY KEY (msgid, tool) ); -''' +""" # --------------------------------------------------------------------------- # Cache database # --------------------------------------------------------------------------- + def _get_db_path() -> str: """Return the path to the CI check cache database.""" datadir = b4.get_data_dir() @@ -75,23 +77,25 @@ def get_db() -> sqlite3.Connection: conn.executescript(SCHEMA_SQL) conn.execute( 'INSERT OR REPLACE INTO schema_version (version) VALUES (?)', - (SCHEMA_VERSION,)) + (SCHEMA_VERSION,), + ) conn.commit() return conn def cleanup_old(conn: sqlite3.Connection, max_days: int = 180) -> int: """Delete check results older than *max_days*. Returns count deleted.""" - cutoff = (datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(days=max_days)).isoformat() - cursor = conn.execute( - 'DELETE FROM check_results WHERE checked_at < ?', (cutoff,)) + cutoff = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=max_days) + ).isoformat() + cursor = conn.execute('DELETE FROM check_results WHERE checked_at < ?', (cutoff,)) conn.commit() return cursor.rowcount -def get_cached_results(conn: sqlite3.Connection, - msgids: List[str]) -> Dict[str, List[Dict[str, str]]]: +def get_cached_results( + conn: sqlite3.Connection, msgids: List[str] +) -> Dict[str, List[Dict[str, str]]]: """Return cached check results keyed by msgid. Returns ``{msgid: [{tool, status, summary, url, details}, ...]}``. @@ -102,7 +106,8 @@ def get_cached_results(conn: sqlite3.Connection, cursor = conn.execute( 'SELECT msgid, tool, status, summary, url, details' f' FROM check_results WHERE msgid IN ({placeholders})', - msgids) + msgids, + ) results: Dict[str, List[Dict[str, str]]] = {} for row in cursor.fetchall(): entry = { @@ -116,29 +121,33 @@ def get_cached_results(conn: sqlite3.Connection, return results -def store_results(conn: sqlite3.Connection, msgid: str, - results: List[Dict[str, str]]) -> None: +def store_results( + conn: sqlite3.Connection, msgid: str, results: List[Dict[str, str]] +) -> None: """Store check results for a single message.""" for entry in results: conn.execute( 'INSERT OR REPLACE INTO check_results' ' (msgid, tool, status, summary, url, details)' ' VALUES (?, ?, ?, ?, ?, ?)', - (msgid, entry['tool'], entry['status'], - entry.get('summary', ''), entry.get('url', ''), - entry.get('details', ''))) + ( + msgid, + entry['tool'], + entry['status'], + entry.get('summary', ''), + entry.get('url', ''), + entry.get('details', ''), + ), + ) conn.commit() -def delete_results(conn: sqlite3.Connection, - msgids: List[str]) -> None: +def delete_results(conn: sqlite3.Connection, msgids: List[str]) -> None: """Delete all cached check results for the given message-ids.""" if not msgids: return placeholders = ','.join('?' * len(msgids)) - conn.execute( - f'DELETE FROM check_results WHERE msgid IN ({placeholders})', - msgids) + conn.execute(f'DELETE FROM check_results WHERE msgid IN ({placeholders})', msgids) conn.commit() @@ -146,6 +155,7 @@ def delete_results(conn: sqlite3.Connection, # Config helpers # --------------------------------------------------------------------------- + def load_check_cmds() -> Tuple[List[str], List[str]]: """Read check commands from git config. @@ -168,7 +178,11 @@ def load_check_cmds() -> Tuple[List[str], List[str]]: if os.access(checkpatch, os.X_OK): perpatch = ['_builtin_checkpatch'] # Auto-wire patchwork CI when project is configured - if '_builtin_patchwork' not in perpatch and config.get('pw-project') and config.get('pw-url'): + if ( + '_builtin_patchwork' not in perpatch + and config.get('pw-project') + and config.get('pw-url') + ): perpatch.append('_builtin_patchwork') series = _as_list(config.get('review-series-check-cmd')) # Auto-wire sashiko AI review when URL is configured @@ -191,16 +205,18 @@ def parse_cmd(cmdstr: str) -> List[str]: # Built-in handlers # --------------------------------------------------------------------------- -def _run_builtin_checkpatch(msg: EmailMessage, - topdir: str) -> List[Dict[str, str]]: + +def _run_builtin_checkpatch(msg: EmailMessage, topdir: str) -> List[Dict[str, str]]: """Run scripts/checkpatch.pl on a single patch message.""" checkpatch = os.path.join(topdir, 'scripts', 'checkpatch.pl') if not os.access(checkpatch, os.X_OK): - return [{ - 'tool': 'checkpatch', - 'status': 'fail', - 'summary': 'checkpatch.pl not found or not executable', - }] + return [ + { + 'tool': 'checkpatch', + 'status': 'fail', + 'summary': 'checkpatch.pl not found or not executable', + } + ] cmdargs = [checkpatch, '-q', '--terse', '--no-summary', '--mailback'] bdata = b4.LoreMessage.get_msg_as_bytes(msg) @@ -211,7 +227,7 @@ def _run_builtin_checkpatch(msg: EmailMessage, findings: List[Dict[str, str]] = [] worst = 'pass' - for raw in (out_str.splitlines() + err_str.splitlines()): + for raw in out_str.splitlines() + err_str.splitlines(): line = raw[2:] if raw.startswith('-:') else raw if not line: continue @@ -231,16 +247,20 @@ def _run_builtin_checkpatch(msg: EmailMessage, if not findings: if ecode: - return [{ + return [ + { + 'tool': 'checkpatch', + 'status': 'fail', + 'summary': f'exited with error code {ecode}', + } + ] + return [ + { 'tool': 'checkpatch', - 'status': 'fail', - 'summary': f'exited with error code {ecode}', - }] - return [{ - 'tool': 'checkpatch', - 'status': 'pass', - 'summary': 'passed all checks', - }] + 'status': 'pass', + 'summary': 'passed all checks', + } + ] errors = sum(1 for f in findings if f['status'] == 'fail') warnings = sum(1 for f in findings if f['status'] == 'warn') @@ -251,16 +271,19 @@ def _run_builtin_checkpatch(msg: EmailMessage, parts.append(f'{warnings} warning{"s" if warnings != 1 else ""}') summary = ', '.join(parts) if parts else findings[0]['description'] - return [{ - 'tool': 'checkpatch', - 'status': worst, - 'summary': summary, - 'details': json.dumps(findings), - }] + return [ + { + 'tool': 'checkpatch', + 'status': worst, + 'summary': summary, + 'details': json.dumps(findings), + } + ] -def _run_builtin_patchwork(msg: EmailMessage, pwkey: str, - pwurl: str) -> List[Dict[str, str]]: +def _run_builtin_patchwork( + msg: EmailMessage, pwkey: str, pwurl: str +) -> List[Dict[str, str]]: """Query Patchwork REST API for checks on a single patch.""" msgid = msg.get('message-id', '').strip('<> ') if not msgid: @@ -278,6 +301,7 @@ def _run_builtin_patchwork(msg: EmailMessage, pwkey: str, try: from b4.review import pw_fetch_checks + checks = pw_fetch_checks(pwkey, pwurl, [int(patch_id)]) except Exception as ex: logger.debug('Patchwork check query failed: %s', ex) @@ -301,25 +325,29 @@ def _run_builtin_patchwork(msg: EmailMessage, pwkey: str, if _STATUS_ORDER.get(status, 0) > _STATUS_ORDER.get(worst, 0): worst = status counts[status] = counts.get(status, 0) + 1 - individual.append({ - 'context': check.get('context', 'unknown'), - 'status': status, - 'state': state, - 'description': check.get('description', ''), - 'url': check.get('url', ''), - }) + individual.append( + { + 'context': check.get('context', 'unknown'), + 'status': status, + 'state': state, + 'description': check.get('description', ''), + 'url': check.get('url', ''), + } + ) summary_parts = [] for s in ('pass', 'warn', 'fail'): if counts.get(s): summary_parts.append(f'{counts[s]} {s}') - return [{ - 'tool': 'patchwork', - 'status': worst, - 'summary': ', '.join(summary_parts), - 'details': json.dumps(individual), - }] + return [ + { + 'tool': 'patchwork', + 'status': worst, + 'summary': ', '.join(summary_parts), + 'details': json.dumps(individual), + } + ] def _fetch_sashiko_patchset(msgid: str, sashiko_url: str) -> Optional[Dict[str, Any]]: @@ -385,12 +413,14 @@ def _parse_sashiko_findings(review: Dict[str, Any]) -> List[Dict[str, str]]: desc = str(problem) if suggestion: desc += f' \u2014 {suggestion}' - findings.append({ - 'status': status, - 'context': f'sashiko/{severity}', - 'state': severity, - 'description': desc, - }) + findings.append( + { + 'status': status, + 'context': f'sashiko/{severity}', + 'state': severity, + 'description': desc, + } + ) return findings @@ -415,8 +445,7 @@ def _sashiko_findings_summary(findings: List[Dict[str, str]]) -> Tuple[str, str] return worst, ', '.join(parts) -def _run_builtin_sashiko(msg: EmailMessage, - sashiko_url: str) -> List[Dict[str, str]]: +def _run_builtin_sashiko(msg: EmailMessage, sashiko_url: str) -> List[Dict[str, str]]: """Query sashiko AI review service for findings on a patch.""" msgid = msg.get('message-id', '').strip('<> ') if not msgid: @@ -442,20 +471,37 @@ def _run_builtin_sashiko(msg: EmailMessage, patch_id_by_msgid[p_msgid] = int(p_id) cover_msgid = data.get('message_id', '') - is_cover = (msgid == cover_msgid) + is_cover = msgid == cover_msgid # Overall patchset status check (applies to cover letter row or # when the series is not yet reviewed). if ps_status in ('Pending', 'In Review', 'Applying'): - return [{'tool': 'sashiko', 'status': 'warn', - 'summary': f'Review {ps_status.lower()}', - 'url': patchset_url}] + return [ + { + 'tool': 'sashiko', + 'status': 'warn', + 'summary': f'Review {ps_status.lower()}', + 'url': patchset_url, + } + ] if ps_status in ('Failed', 'Failed To Apply'): - return [{'tool': 'sashiko', 'status': 'fail', - 'summary': ps_status, 'url': patchset_url}] + return [ + { + 'tool': 'sashiko', + 'status': 'fail', + 'summary': ps_status, + 'url': patchset_url, + } + ] if ps_status == 'Incomplete': - return [{'tool': 'sashiko', 'status': 'warn', - 'summary': 'Series incomplete', 'url': patchset_url}] + return [ + { + 'tool': 'sashiko', + 'status': 'warn', + 'summary': 'Series incomplete', + 'url': patchset_url, + } + ] if is_cover: # Aggregate findings across all reviews for the cover letter @@ -464,8 +510,10 @@ def _run_builtin_sashiko(msg: EmailMessage, all_findings.extend(_parse_sashiko_findings(review)) worst, summary = _sashiko_findings_summary(all_findings) result: Dict[str, str] = { - 'tool': 'sashiko', 'status': worst, - 'summary': summary, 'url': patchset_url, + 'tool': 'sashiko', + 'status': worst, + 'summary': summary, + 'url': patchset_url, } if all_findings: result['details'] = json.dumps(all_findings) @@ -481,40 +529,68 @@ def _run_builtin_sashiko(msg: EmailMessage, review_status = review.get('status', '') if review_status == 'Skipped': result_msg = review.get('result', '') or 'Skipped' - return [{'tool': 'sashiko', 'status': 'pass', - 'summary': result_msg, 'url': patchset_url}] + return [ + { + 'tool': 'sashiko', + 'status': 'pass', + 'summary': result_msg, + 'url': patchset_url, + } + ] if review_status in ('Pending', 'In Review'): - return [{'tool': 'sashiko', 'status': 'warn', - 'summary': 'Review in progress', - 'url': patchset_url}] + return [ + { + 'tool': 'sashiko', + 'status': 'warn', + 'summary': 'Review in progress', + 'url': patchset_url, + } + ] if review_status == 'Failed': result_msg = review.get('result', '') or 'Review failed' - return [{'tool': 'sashiko', 'status': 'fail', - 'summary': result_msg, 'url': patchset_url}] + return [ + { + 'tool': 'sashiko', + 'status': 'fail', + 'summary': result_msg, + 'url': patchset_url, + } + ] # Reviewed — parse findings findings = _parse_sashiko_findings(review) worst, summary = _sashiko_findings_summary(findings) result = { - 'tool': 'sashiko', 'status': worst, - 'summary': summary, 'url': patchset_url, + 'tool': 'sashiko', + 'status': worst, + 'summary': summary, + 'url': patchset_url, } if findings: result['details'] = json.dumps(findings) return [result] # No review found for this patch - return [{'tool': 'sashiko', 'status': 'pass', - 'summary': 'No review', 'url': patchset_url}] + return [ + { + 'tool': 'sashiko', + 'status': 'pass', + 'summary': 'No review', + 'url': patchset_url, + } + ] # --------------------------------------------------------------------------- # External command runner # --------------------------------------------------------------------------- -def _run_external_cmd(cmdargs: List[str], msg: EmailMessage, - topdir: str, - extra_env: Optional[Dict[str, str]] = None, - ) -> List[Dict[str, str]]: + +def _run_external_cmd( + cmdargs: List[str], + msg: EmailMessage, + topdir: str, + extra_env: Optional[Dict[str, str]] = None, +) -> List[Dict[str, str]]: """Run an external check command and parse its JSON output.""" bdata = b4.LoreMessage.get_msg_as_bytes(msg) saved_env: Dict[str, Optional[str]] = {} @@ -535,24 +611,28 @@ def _run_external_cmd(cmdargs: List[str], msg: EmailMessage, if ecode: mycmd = os.path.basename(cmdargs[0]) err_msg = err.strip().decode(errors='replace') if err else '' - return [{ - 'tool': mycmd, - 'status': 'fail', - 'summary': f'exited with error code {ecode}', - 'details': err_msg, - }] + return [ + { + 'tool': mycmd, + 'status': 'fail', + 'summary': f'exited with error code {ecode}', + 'details': err_msg, + } + ] return [] try: data = json.loads(out) except json.JSONDecodeError as ex: mycmd = os.path.basename(cmdargs[0]) - return [{ - 'tool': mycmd, - 'status': 'fail', - 'summary': f'invalid JSON output: {ex}', - 'details': out.decode(errors='replace'), - }] + return [ + { + 'tool': mycmd, + 'status': 'fail', + 'summary': f'invalid JSON output: {ex}', + 'details': out.decode(errors='replace'), + } + ] if not isinstance(data, list): data = [data] @@ -565,13 +645,15 @@ def _run_external_cmd(cmdargs: List[str], msg: EmailMessage, status = entry.get('status', 'fail') if status not in ('pass', 'warn', 'fail'): status = 'fail' - results.append({ - 'tool': tool, - 'status': status, - 'summary': entry.get('summary', ''), - 'url': entry.get('url', ''), - 'details': entry.get('details', ''), - }) + results.append( + { + 'tool': tool, + 'status': status, + 'summary': entry.get('summary', ''), + 'url': entry.get('url', ''), + 'details': entry.get('details', ''), + } + ) return results @@ -579,10 +661,15 @@ def _run_external_cmd(cmdargs: List[str], msg: EmailMessage, # High-level runners # --------------------------------------------------------------------------- -def _dispatch_cmd(cmdstr: str, msg: EmailMessage, topdir: str, - pwkey: str = '', pwurl: str = '', - extra_env: Optional[Dict[str, str]] = None, - ) -> List[Dict[str, str]]: + +def _dispatch_cmd( + cmdstr: str, + msg: EmailMessage, + topdir: str, + pwkey: str = '', + pwurl: str = '', + extra_env: Optional[Dict[str, str]] = None, +) -> List[Dict[str, str]]: """Run a single check command (built-in or external) against a message.""" if cmdstr == '_builtin_checkpatch': return _run_builtin_checkpatch(msg, topdir) @@ -604,12 +691,12 @@ def _dispatch_cmd(cmdstr: str, msg: EmailMessage, topdir: str, def run_perpatch_checks( - patches: List[Tuple[str, EmailMessage]], - cmds: List[str], - topdir: str, - pwkey: str = '', - pwurl: str = '', - extra_env: Optional[Dict[str, str]] = None, + patches: List[Tuple[str, EmailMessage]], + cmds: List[str], + topdir: str, + pwkey: str = '', + pwurl: str = '', + extra_env: Optional[Dict[str, str]] = None, ) -> Dict[str, List[Dict[str, str]]]: """Run per-patch check commands on each patch. @@ -622,27 +709,31 @@ def run_perpatch_checks( patch_results: List[Dict[str, str]] = [] for cmdstr in cmds: try: - patch_results.extend(_dispatch_cmd(cmdstr, msg, topdir, - pwkey, pwurl, - extra_env=extra_env)) + patch_results.extend( + _dispatch_cmd( + cmdstr, msg, topdir, pwkey, pwurl, extra_env=extra_env + ) + ) except Exception as ex: logger.debug('Check command %s failed: %s', cmdstr, ex) - patch_results.append({ - 'tool': cmdstr.split()[0] if cmdstr else 'unknown', - 'status': 'fail', - 'summary': str(ex), - }) + patch_results.append( + { + 'tool': cmdstr.split()[0] if cmdstr else 'unknown', + 'status': 'fail', + 'summary': str(ex), + } + ) results[msgid] = patch_results return results def run_series_checks( - cover_msg: Tuple[str, EmailMessage], - cmds: List[str], - topdir: str, - pwkey: str = '', - pwurl: str = '', - extra_env: Optional[Dict[str, str]] = None, + cover_msg: Tuple[str, EmailMessage], + cmds: List[str], + topdir: str, + pwkey: str = '', + pwurl: str = '', + extra_env: Optional[Dict[str, str]] = None, ) -> List[Dict[str, str]]: """Run per-series check commands on the cover letter. @@ -654,14 +745,16 @@ def run_series_checks( results: List[Dict[str, str]] = [] for cmdstr in cmds: try: - results.extend(_dispatch_cmd(cmdstr, msg, topdir, - pwkey, pwurl, - extra_env=extra_env)) + results.extend( + _dispatch_cmd(cmdstr, msg, topdir, pwkey, pwurl, extra_env=extra_env) + ) except Exception as ex: logger.debug('Series check command %s failed: %s', cmdstr, ex) - results.append({ - 'tool': cmdstr.split()[0] if cmdstr else 'unknown', - 'status': 'fail', - 'summary': str(ex), - }) + results.append( + { + 'tool': cmdstr.split()[0] if cmdstr else 'unknown', + 'status': 'fail', + 'summary': str(ex), + } + ) return results diff --git a/src/b4/review/messages.py b/src/b4/review/messages.py index 3a0098c..5d6456e 100644 --- a/src/b4/review/messages.py +++ b/src/b4/review/messages.py @@ -12,7 +12,7 @@ from typing import Dict, List, Optional import b4 -SCHEMA_SQL = ''' +SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY ); @@ -22,7 +22,7 @@ CREATE TABLE IF NOT EXISTS messages ( msg_date TEXT, flags TEXT DEFAULT '' ); -''' +""" SCHEMA_VERSION = 1 @@ -45,7 +45,8 @@ def get_db() -> sqlite3.Connection: conn.executescript(SCHEMA_SQL) conn.execute( 'INSERT OR REPLACE INTO schema_version (version) VALUES (?)', - (SCHEMA_VERSION,)) + (SCHEMA_VERSION,), + ) conn.commit() return conn @@ -53,45 +54,49 @@ def get_db() -> sqlite3.Connection: def get_flags(conn: sqlite3.Connection, msgid: str) -> str: """Return the flags string for a message, or '' if not stored.""" row = conn.execute( - 'SELECT flags FROM messages WHERE msgid = ?', (msgid,)).fetchone() + 'SELECT flags FROM messages WHERE msgid = ?', (msgid,) + ).fetchone() return row[0] if row else '' -def get_flags_bulk(conn: sqlite3.Connection, - msgids: List[str]) -> Dict[str, str]: +def get_flags_bulk(conn: sqlite3.Connection, msgids: List[str]) -> Dict[str, str]: """Return {msgid: flags} for all known messages in *msgids*.""" if not msgids: return {} placeholders = ','.join('?' * len(msgids)) cursor = conn.execute( - f'SELECT msgid, flags FROM messages WHERE msgid IN ({placeholders})', - msgids) + f'SELECT msgid, flags FROM messages WHERE msgid IN ({placeholders})', msgids + ) return {row[0]: row[1] for row in cursor.fetchall()} -def set_flag(conn: sqlite3.Connection, msgid: str, flag: str, - msg_date: Optional[str] = None) -> None: +def set_flag( + conn: sqlite3.Connection, msgid: str, flag: str, msg_date: Optional[str] = None +) -> None: """Add *flag* to a message, creating the row if needed.""" conn.execute( 'INSERT INTO messages (msgid, msg_date, flags)' ' VALUES (?, ?, ?)' ' ON CONFLICT(msgid) DO NOTHING', - (msgid, msg_date, flag)) + (msgid, msg_date, flag), + ) row = conn.execute( - 'SELECT flags FROM messages WHERE msgid = ?', (msgid,)).fetchone() + 'SELECT flags FROM messages WHERE msgid = ?', (msgid,) + ).fetchone() if row: existing = set(row[0].split()) if flag not in existing: existing.add(flag) conn.execute( 'UPDATE messages SET flags = ? WHERE msgid = ?', - (' '.join(sorted(existing)), msgid)) + (' '.join(sorted(existing)), msgid), + ) conn.commit() -def set_flags_bulk(conn: sqlite3.Connection, - entries: List[Dict[str, Optional[str]]], - flag: str) -> None: +def set_flags_bulk( + conn: sqlite3.Connection, entries: List[Dict[str, Optional[str]]], flag: str +) -> None: """Add *flag* to multiple messages in one transaction. Each entry in *entries* is ``{'msgid': ..., 'msg_date': ...}``. @@ -105,24 +110,27 @@ def set_flags_bulk(conn: sqlite3.Connection, 'INSERT INTO messages (msgid, msg_date, flags)' ' VALUES (?, ?, ?)' ' ON CONFLICT(msgid) DO NOTHING', - (msgid, msg_date, flag)) + (msgid, msg_date, flag), + ) row = conn.execute( - 'SELECT flags FROM messages WHERE msgid = ?', - (msgid,)).fetchone() + 'SELECT flags FROM messages WHERE msgid = ?', (msgid,) + ).fetchone() if row: existing = set(row[0].split()) if flag not in existing: existing.add(flag) conn.execute( 'UPDATE messages SET flags = ? WHERE msgid = ?', - (' '.join(sorted(existing)), msgid)) + (' '.join(sorted(existing)), msgid), + ) conn.commit() def remove_flag(conn: sqlite3.Connection, msgid: str, flag: str) -> None: """Remove *flag* from a message. Deletes the row if no flags remain.""" row = conn.execute( - 'SELECT flags FROM messages WHERE msgid = ?', (msgid,)).fetchone() + 'SELECT flags FROM messages WHERE msgid = ?', (msgid,) + ).fetchone() if not row: return existing = set(row[0].split()) @@ -130,7 +138,8 @@ def remove_flag(conn: sqlite3.Connection, msgid: str, flag: str) -> None: if existing: conn.execute( 'UPDATE messages SET flags = ? WHERE msgid = ?', - (' '.join(sorted(existing)), msgid)) + (' '.join(sorted(existing)), msgid), + ) else: conn.execute('DELETE FROM messages WHERE msgid = ?', (msgid,)) conn.commit() @@ -139,10 +148,12 @@ def remove_flag(conn: sqlite3.Connection, msgid: str, flag: str) -> None: def cleanup_old(conn: sqlite3.Connection, max_days: int = 180) -> int: """Delete messages older than *max_days*. Returns count deleted.""" import datetime - cutoff = (datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(days=max_days)).isoformat() + + cutoff = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=max_days) + ).isoformat() cursor = conn.execute( - 'DELETE FROM messages WHERE msg_date IS NOT NULL AND msg_date < ?', - (cutoff,)) + 'DELETE FROM messages WHERE msg_date IS NOT NULL AND msg_date < ?', (cutoff,) + ) conn.commit() return cursor.rowcount diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py index eb80eea..ee0d218 100644 --- a/src/b4/review/tracking.py +++ b/src/b4/review/tracking.py @@ -26,7 +26,7 @@ REVIEW_METADATA_FILE = 'metadata.json' SCHEMA_VERSION = 8 -SERIES_PATCHES_DDL = ''' +SERIES_PATCHES_DDL = """ CREATE TABLE IF NOT EXISTS series_patches ( change_id TEXT NOT NULL, revision INTEGER NOT NULL, @@ -34,9 +34,10 @@ CREATE TABLE IF NOT EXISTS series_patches ( message_id TEXT NOT NULL, subject TEXT, PRIMARY KEY (change_id, revision, position) -)''' +)""" -SCHEMA_SQL = ''' +SCHEMA_SQL = ( + """ CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY ); @@ -78,7 +79,10 @@ CREATE TABLE IF NOT EXISTS revisions ( PRIMARY KEY (change_id, revision) ); -''' + SERIES_PATCHES_DDL + ';' +""" + + SERIES_PATCHES_DDL + + ';' +) def get_review_data_dir() -> str: @@ -105,7 +109,9 @@ def init_db(identifier: str) -> sqlite3.Connection: db_path = get_db_path(identifier) conn = sqlite3.connect(db_path) conn.executescript(SCHEMA_SQL) - conn.execute('INSERT OR REPLACE INTO schema_version (version) VALUES (?)', (SCHEMA_VERSION,)) + conn.execute( + 'INSERT OR REPLACE INTO schema_version (version) VALUES (?)', (SCHEMA_VERSION,) + ) conn.commit() return conn @@ -127,7 +133,9 @@ def _migrate_db_if_needed(conn: sqlite3.Connection) -> None: conn.execute('ALTER TABLE series ADD COLUMN snoozed_until TEXT') if version < 4: conn.execute('ALTER TABLE series RENAME COLUMN followup_count TO message_count') - conn.execute('ALTER TABLE series RENAME COLUMN seen_followup_count TO seen_message_count') + conn.execute( + 'ALTER TABLE series RENAME COLUMN seen_followup_count TO seen_message_count' + ) if version < 5: conn.execute("ALTER TABLE series ADD COLUMN attestation TEXT DEFAULT 'pending'") if version < 6: @@ -137,7 +145,7 @@ def _migrate_db_if_needed(conn: sqlite3.Connection) -> None: conn.execute('ALTER TABLE series ADD COLUMN is_rethreaded INTEGER DEFAULT 0') if version < 8: # Older DBs may not have the revisions table at all; create it if absent. - conn.execute('''CREATE TABLE IF NOT EXISTS revisions ( + conn.execute("""CREATE TABLE IF NOT EXISTS revisions ( change_id TEXT NOT NULL, revision INTEGER NOT NULL, message_id TEXT NOT NULL, @@ -145,7 +153,7 @@ def _migrate_db_if_needed(conn: sqlite3.Connection) -> None: link TEXT, found_at TEXT, PRIMARY KEY (change_id, revision) - )''') + )""") # Add thread_blob only if the table didn't already have it. existing = {row[1] for row in conn.execute('PRAGMA table_info(revisions)')} if 'thread_blob' not in existing: @@ -242,7 +250,9 @@ def record_take_branch(gitdir: str, branch: str) -> None: f.write('\n') -def resolve_identifier(cmdargs: argparse.Namespace, topdir: Optional[str] = None) -> Optional[str]: +def resolve_identifier( + cmdargs: argparse.Namespace, topdir: Optional[str] = None +) -> Optional[str]: """Resolve project identifier from command args or repository metadata.""" if hasattr(cmdargs, 'identifier') and cmdargs.identifier: return str(cmdargs.identifier) @@ -264,7 +274,9 @@ def cmd_enroll(cmdargs: argparse.Namespace) -> None: # Use current directory repo_path_opt = b4.git_get_toplevel() if not repo_path_opt: - logger.critical('Not in a git repository. Specify a path or run from within a repository.') + logger.critical( + 'Not in a git repository. Specify a path or run from within a repository.' + ) sys.exit(1) repo_path = repo_path_opt @@ -319,18 +331,26 @@ def cmd_enroll(cmdargs: argparse.Namespace) -> None: logger.info('Project enrolled successfully with identifier: %s', identifier) -def add_series_to_db(conn: sqlite3.Connection, change_id: str, revision: int, - subject: Optional[str], sender_name: Optional[str], - sender_email: Optional[str], sent_at: Optional[str], - message_id: str, num_patches: int, - pw_series_id: Optional[int] = None, - fingerprint: Optional[str] = None, - added_at: Optional[str] = None, - is_rethreaded: bool = False) -> int: +def add_series_to_db( + conn: sqlite3.Connection, + change_id: str, + revision: int, + subject: Optional[str], + sender_name: Optional[str], + sender_email: Optional[str], + sent_at: Optional[str], + message_id: str, + num_patches: int, + pw_series_id: Optional[int] = None, + fingerprint: Optional[str] = None, + added_at: Optional[str] = None, + is_rethreaded: bool = False, +) -> int: """Add a series to the tracking database. Returns the track_id.""" if added_at is None: added_at = datetime.datetime.now(datetime.timezone.utc).isoformat() - cursor = conn.execute(''' + cursor = conn.execute( + """ INSERT INTO series (change_id, revision, subject, sender_name, sender_email, sent_at, added_at, message_id, num_patches, pw_series_id, fingerprint, is_rethreaded) @@ -347,8 +367,22 @@ def add_series_to_db(conn: sqlite3.Connection, change_id: str, revision: int, fingerprint = excluded.fingerprint, is_rethreaded = excluded.is_rethreaded RETURNING track_id - ''', (change_id, revision, subject, sender_name, sender_email, sent_at, added_at, - message_id, num_patches, pw_series_id, fingerprint, int(is_rethreaded))) + """, + ( + change_id, + revision, + subject, + sender_name, + sender_email, + sent_at, + added_at, + message_id, + num_patches, + pw_series_id, + fingerprint, + int(is_rethreaded), + ), + ) track_id = cursor.fetchone()[0] conn.commit() return int(track_id) @@ -381,7 +415,9 @@ def cmd_track(cmdargs: argparse.Namespace) -> None: rethread = getattr(cmdargs, 'rethread', None) if rethread: if cmdargs.series_id: - logger.critical('--rethread cannot be used with a positional series_id argument') + logger.critical( + '--rethread cannot be used with a positional series_id argument' + ) sys.exit(1) series_id = None else: @@ -390,7 +426,9 @@ def cmd_track(cmdargs: argparse.Namespace) -> None: series_id = b4.get_msgid_from_stdin() if not series_id: logger.critical('No series identifier provided') - logger.critical('Pipe a message or pass msgid/URL/change-id as parameter') + logger.critical( + 'Pipe a message or pass msgid/URL/change-id as parameter' + ) sys.exit(1) # Set up cmdargs for retrieve_messages @@ -430,16 +468,15 @@ def cmd_track(cmdargs: argparse.Namespace) -> None: if b4.can_network: msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True) if wanted_ver > 1: - msgs = b4.mbox.get_extra_series(msgs, direction=-1, - wantvers=list(range(1, wanted_ver)), - nocache=True) + msgs = b4.mbox.get_extra_series( + msgs, direction=-1, wantvers=list(range(1, wanted_ver)), nocache=True + ) # Rebuild the mailbox with all discovered messages lmbx = b4.LoreMailbox() for msg in msgs: lmbx.add_message(msg) - lser = lmbx.get_series(wanted_ver, sloppytrailers=False, - codereview_trailers=False) + lser = lmbx.get_series(wanted_ver, sloppytrailers=False, codereview_trailers=False) if not lser: logger.critical('Could not find series version %d', wanted_ver) sys.exit(1) @@ -492,8 +529,9 @@ def cmd_track(cmdargs: argparse.Namespace) -> None: ).fetchone() if existing is not None: conn.close() - logger.critical('This series is already tracked (status: %s, v%d)', - existing[0], existing[1]) + logger.critical( + 'This series is already tracked (status: %s, v%d)', existing[0], existing[1] + ) logger.critical('Change-ID: %s', existing[2]) sys.exit(1) conn.close() @@ -506,9 +544,19 @@ def cmd_track(cmdargs: argparse.Namespace) -> None: # Add to database subject = lser.subject conn = get_db(identifier) - add_series_to_db(conn, change_id, revision, subject, sender_name, sender_email, - sent_at, message_id, num_patches, fingerprint=fingerprint, - is_rethreaded=bool(rethread)) + add_series_to_db( + conn, + change_id, + revision, + subject, + sender_name, + sender_email, + sent_at, + message_id, + num_patches, + fingerprint=fingerprint, + is_rethreaded=bool(rethread), + ) add_series_patches(conn, change_id, revision, lser) # Record all discovered revisions @@ -523,7 +571,9 @@ def cmd_track(cmdargs: argparse.Namespace) -> None: for p in v_ser.patches: if p is not None: v_msgid = str(getattr(p, 'msgid', '')) - v_subject = str(getattr(p, 'full_subject', '') or getattr(p, 'subject', '')) + v_subject = str( + getattr(p, 'full_subject', '') or getattr(p, 'subject', '') + ) break except Exception: pass @@ -549,7 +599,9 @@ def get_tracked_pw_series_ids(identifier: str) -> set[int]: return set() try: conn = get_db(identifier) - cursor = conn.execute('SELECT pw_series_id FROM series WHERE pw_series_id IS NOT NULL') + cursor = conn.execute( + 'SELECT pw_series_id FROM series WHERE pw_series_id IS NOT NULL' + ) result = {row[0] for row in cursor.fetchall()} conn.close() return result @@ -564,8 +616,7 @@ def is_pw_series_tracked(identifier: str, pw_series_id: int) -> bool: try: conn = get_db(identifier) cursor = conn.execute( - 'SELECT 1 FROM series WHERE pw_series_id = ? LIMIT 1', - (pw_series_id,) + 'SELECT 1 FROM series WHERE pw_series_id = ? LIMIT 1', (pw_series_id,) ) result = cursor.fetchone() is not None conn.close() @@ -585,57 +636,67 @@ def get_all_tracked_series(identifier: str) -> list[dict[str, Any]]: return [] try: conn = get_db(identifier) - cursor = conn.execute(''' + cursor = conn.execute(""" SELECT track_id, change_id, revision, subject, sender_name, sender_email, sent_at, added_at, status, num_patches, message_id, pw_series_id, message_count, seen_message_count, last_activity_at, attestation, target_branch, is_rethreaded, snoozed_until FROM series ORDER BY added_at DESC - ''') + """) result = [] for row in cursor.fetchall(): - result.append({ - 'track_id': row[0], - 'change_id': row[1], - 'revision': row[2], - 'subject': row[3] or '(no subject)', - 'sender_name': row[4] or 'Unknown', - 'sender_email': row[5] or '', - 'sent_at': row[6] or '', - 'added_at': row[7] or '', - 'status': row[8] or 'new', - 'num_patches': row[9] or 0, - 'message_id': row[10] or '', - 'pw_series_id': row[11], - 'message_count': row[12], - 'seen_message_count': row[13], - 'last_activity_at': row[14], - 'attestation': row[15], - 'target_branch': row[16], - 'is_rethreaded': bool(row[17]), - 'snoozed_until': row[18], - }) + result.append( + { + 'track_id': row[0], + 'change_id': row[1], + 'revision': row[2], + 'subject': row[3] or '(no subject)', + 'sender_name': row[4] or 'Unknown', + 'sender_email': row[5] or '', + 'sent_at': row[6] or '', + 'added_at': row[7] or '', + 'status': row[8] or 'new', + 'num_patches': row[9] or 0, + 'message_id': row[10] or '', + 'pw_series_id': row[11], + 'message_count': row[12], + 'seen_message_count': row[13], + 'last_activity_at': row[14], + 'attestation': row[15], + 'target_branch': row[16], + 'is_rethreaded': bool(row[17]), + 'snoozed_until': row[18], + } + ) conn.close() return result except Exception: return [] -def add_revision(conn: sqlite3.Connection, change_id: str, revision: int, - message_id: str, subject: Optional[str] = None, - link: Optional[str] = None) -> None: +def add_revision( + conn: sqlite3.Connection, + change_id: str, + revision: int, + message_id: str, + subject: Optional[str] = None, + link: Optional[str] = None, +) -> None: """Insert a revision record, ignoring if already present.""" found_at = datetime.datetime.now(datetime.timezone.utc).isoformat() - conn.execute('''INSERT OR IGNORE INTO revisions + conn.execute( + """INSERT OR IGNORE INTO revisions (change_id, revision, message_id, subject, link, found_at) - VALUES (?, ?, ?, ?, ?, ?)''', - (change_id, revision, message_id, subject, link, found_at)) + VALUES (?, ?, ?, ?, ?, ?)""", + (change_id, revision, message_id, subject, link, found_at), + ) conn.commit() -def set_revision_thread_blob(conn: sqlite3.Connection, change_id: str, - revision: int, blob_sha: str) -> None: +def set_revision_thread_blob( + conn: sqlite3.Connection, change_id: str, revision: int, blob_sha: str +) -> None: """Record the git blob SHA of the cached mbox thread for a revision. The blob may later become unreachable (GC'd), so callers that read this @@ -643,19 +704,23 @@ def set_revision_thread_blob(conn: sqlite3.Connection, change_id: str, """ conn.execute( 'UPDATE revisions SET thread_blob = ? WHERE change_id = ? AND revision = ?', - (blob_sha, change_id, revision)) + (blob_sha, change_id, revision), + ) conn.commit() -def add_series_patches(conn: sqlite3.Connection, change_id: str, revision: int, - lser: 'b4.LoreSeries') -> None: +def add_series_patches( + conn: sqlite3.Connection, change_id: str, revision: int, lser: 'b4.LoreSeries' +) -> None: """Store the member patches for a tracked series. Iterates lser.patches and inserts one row per non-None patch. Deletes any existing rows first so the call is idempotent. """ - conn.execute('DELETE FROM series_patches WHERE change_id = ? AND revision = ?', - (change_id, revision)) + conn.execute( + 'DELETE FROM series_patches WHERE change_id = ? AND revision = ?', + (change_id, revision), + ) rows = [] for position, lmsg in enumerate(lser.patches): if lmsg is None: @@ -664,37 +729,49 @@ def add_series_patches(conn: sqlite3.Connection, change_id: str, revision: int, if rows: conn.executemany( 'INSERT INTO series_patches (change_id, revision, position, message_id, subject)' - ' VALUES (?, ?, ?, ?, ?)', rows) + ' VALUES (?, ?, ?, ?, ?)', + rows, + ) conn.commit() -def get_series_patches(conn: sqlite3.Connection, change_id: str, - revision: int) -> List[Dict[str, Any]]: +def get_series_patches( + conn: sqlite3.Connection, change_id: str, revision: int +) -> List[Dict[str, Any]]: """Return the stored patches for a series, ordered by position.""" cols = ('position', 'message_id', 'subject') cursor = conn.execute( 'SELECT position, message_id, subject FROM series_patches' ' WHERE change_id = ? AND revision = ? ORDER BY position ASC', - (change_id, revision)) + (change_id, revision), + ) return [dict(zip(cols, row)) for row in cursor.fetchall()] def get_revisions(conn: sqlite3.Connection, change_id: str) -> list[dict[str, Any]]: """Return all known revisions for a change_id, ordered ascending.""" - cols = ('change_id', 'revision', 'message_id', 'subject', 'link', 'found_at', - 'thread_blob') + cols = ( + 'change_id', + 'revision', + 'message_id', + 'subject', + 'link', + 'found_at', + 'thread_blob', + ) cursor = conn.execute( 'SELECT change_id, revision, message_id, subject, link, found_at, thread_blob ' 'FROM revisions WHERE change_id = ? ORDER BY revision ASC', - (change_id,)) + (change_id,), + ) return [dict(zip(cols, row)) for row in cursor.fetchall()] def get_newest_revision(conn: sqlite3.Connection, change_id: str) -> Optional[int]: """Return the highest known revision number, or None.""" cursor = conn.execute( - 'SELECT MAX(revision) FROM revisions WHERE change_id = ?', - (change_id,)) + 'SELECT MAX(revision) FROM revisions WHERE change_id = ?', (change_id,) + ) row = cursor.fetchone() if row and row[0] is not None: return int(row[0]) @@ -704,24 +781,36 @@ def get_newest_revision(conn: sqlite3.Connection, change_id: str) -> Optional[in def get_all_newest_revisions(conn: sqlite3.Connection) -> dict[str, int]: """Return {change_id: max_revision} for all change_ids with revisions.""" cursor = conn.execute( - 'SELECT change_id, MAX(revision) FROM revisions GROUP BY change_id') + 'SELECT change_id, MAX(revision) FROM revisions GROUP BY change_id' + ) return {row[0]: int(row[1]) for row in cursor.fetchall()} def get_all_revision_counts(conn: sqlite3.Connection) -> dict[str, int]: """Return {change_id: revision_count} for all change_ids.""" cursor = conn.execute( - 'SELECT change_id, COUNT(*) FROM revisions GROUP BY change_id') + 'SELECT change_id, COUNT(*) FROM revisions GROUP BY change_id' + ) return {row[0]: int(row[1]) for row in cursor.fetchall()} -def get_all_revisions_grouped(conn: sqlite3.Connection) -> dict[str, list[dict[str, Any]]]: +def get_all_revisions_grouped( + conn: sqlite3.Connection, +) -> dict[str, list[dict[str, Any]]]: """Return {change_id: [rev_dicts]} for all change_ids, ordered ascending.""" - cols = ('change_id', 'revision', 'message_id', 'subject', 'link', 'found_at', - 'thread_blob') + cols = ( + 'change_id', + 'revision', + 'message_id', + 'subject', + 'link', + 'found_at', + 'thread_blob', + ) cursor = conn.execute( 'SELECT change_id, revision, message_id, subject, link, found_at, thread_blob ' - 'FROM revisions ORDER BY change_id, revision ASC') + 'FROM revisions ORDER BY change_id, revision ASC' + ) result: dict[str, list[dict[str, Any]]] = {} for row in cursor.fetchall(): entry = dict(zip(cols, row)) @@ -729,22 +818,28 @@ def get_all_revisions_grouped(conn: sqlite3.Connection) -> dict[str, list[dict[s return result -def update_attestation(identifier: str, change_id: str, - revision: int, attestation: Optional[str]) -> None: +def update_attestation( + identifier: str, change_id: str, revision: int, attestation: Optional[str] +) -> None: """Store attestation result for a tracked series.""" try: conn = get_db(identifier) conn.execute( 'UPDATE series SET attestation = ? WHERE change_id = ? AND revision = ?', - (attestation, change_id, revision)) + (attestation, change_id, revision), + ) conn.commit() conn.close() except Exception: pass -def update_series_status(conn: sqlite3.Connection, change_id: str, status: str, - revision: Optional[int] = None) -> None: +def update_series_status( + conn: sqlite3.Connection, + change_id: str, + status: str, + revision: Optional[int] = None, +) -> None: """Update the status of a tracked series. When *revision* is given only that specific revision is updated; @@ -759,19 +854,24 @@ def update_series_status(conn: sqlite3.Connection, change_id: str, status: str, conn.execute( 'UPDATE series SET status = ?, last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (status, now, change_id, revision)) + (status, now, change_id, revision), + ) else: conn.execute( - 'UPDATE series SET status = ?, last_activity_at = ?' - ' WHERE change_id = ?', - (status, now, change_id)) + 'UPDATE series SET status = ?, last_activity_at = ? WHERE change_id = ?', + (status, now, change_id), + ) conn.commit() -def update_series_revision(conn: sqlite3.Connection, change_id: str, - old_revision: int, new_revision: int, - new_message_id: str, - new_subject: Optional[str] = None) -> None: +def update_series_revision( + conn: sqlite3.Connection, + change_id: str, + old_revision: int, + new_revision: int, + new_message_id: str, + new_subject: Optional[str] = None, +) -> None: """Switch a tracked series to a different revision number. Used when a not-yet-checked-out series should track a different @@ -787,21 +887,25 @@ def update_series_revision(conn: sqlite3.Connection, change_id: str, ' message_count = NULL, seen_message_count = NULL,' ' last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (new_revision, new_message_id, new_subject, now, - change_id, old_revision)) + (new_revision, new_message_id, new_subject, now, change_id, old_revision), + ) else: conn.execute( 'UPDATE series SET revision = ?, message_id = ?,' ' message_count = NULL, seen_message_count = NULL,' ' last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (new_revision, new_message_id, now, - change_id, old_revision)) + (new_revision, new_message_id, now, change_id, old_revision), + ) conn.commit() -def snooze_series(conn: sqlite3.Connection, change_id: str, - until_date: str, revision: Optional[int] = None) -> None: +def snooze_series( + conn: sqlite3.Connection, + change_id: str, + until_date: str, + revision: Optional[int] = None, +) -> None: """Set a series to snoozed status with a wake-up date. Args: @@ -815,17 +919,23 @@ def snooze_series(conn: sqlite3.Connection, change_id: str, conn.execute( 'UPDATE series SET status = ?, snoozed_until = ?, last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - ('snoozed', until_date, now, change_id, revision)) + ('snoozed', until_date, now, change_id, revision), + ) else: conn.execute( 'UPDATE series SET status = ?, snoozed_until = ?, last_activity_at = ?' ' WHERE change_id = ?', - ('snoozed', until_date, now, change_id)) + ('snoozed', until_date, now, change_id), + ) conn.commit() -def unsnooze_series(conn: sqlite3.Connection, change_id: str, - previous_status: str, revision: Optional[int] = None) -> None: +def unsnooze_series( + conn: sqlite3.Connection, + change_id: str, + previous_status: str, + revision: Optional[int] = None, +) -> None: """Restore a snoozed series to its previous status. Args: @@ -839,92 +949,107 @@ def unsnooze_series(conn: sqlite3.Connection, change_id: str, conn.execute( 'UPDATE series SET status = ?, snoozed_until = NULL, last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (previous_status, now, change_id, revision)) + (previous_status, now, change_id, revision), + ) else: conn.execute( 'UPDATE series SET status = ?, snoozed_until = NULL, last_activity_at = ?' ' WHERE change_id = ?', - (previous_status, now, change_id)) + (previous_status, now, change_id), + ) conn.commit() def get_expired_snoozed(conn: sqlite3.Connection) -> List[Dict[str, Any]]: """Return all snoozed series whose wake-up time has passed.""" cursor = conn.execute( - "SELECT change_id, revision, snoozed_until FROM series" + 'SELECT change_id, revision, snoozed_until FROM series' " WHERE status = 'snoozed'" " AND snoozed_until <= strftime('%Y-%m-%dT%H:%M:%S', 'now')" ) results = [] for row in cursor: - results.append({ - 'change_id': row[0], - 'revision': row[1], - 'snoozed_until': row[2], - }) + results.append( + { + 'change_id': row[0], + 'revision': row[1], + 'snoozed_until': row[2], + } + ) return results def get_tag_snoozed(conn: sqlite3.Connection) -> List[Dict[str, Any]]: """Return all snoozed series waiting for a git tag to appear.""" cursor = conn.execute( - "SELECT change_id, revision, snoozed_until FROM series" + 'SELECT change_id, revision, snoozed_until FROM series' " WHERE status = 'snoozed'" " AND snoozed_until LIKE 'tag:%'" ) results = [] for row in cursor: - results.append({ - 'change_id': row[0], - 'revision': row[1], - 'snoozed_until': row[2], - }) + results.append( + { + 'change_id': row[0], + 'revision': row[1], + 'snoozed_until': row[2], + } + ) return results -def get_snoozed_until(conn: sqlite3.Connection, change_id: str, - revision: Optional[int] = None) -> Optional[str]: +def get_snoozed_until( + conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None +) -> Optional[str]: """Return the snoozed_until date for a series, or None.""" if revision is not None: row = conn.execute( 'SELECT snoozed_until FROM series WHERE change_id = ? AND revision = ?', - (change_id, revision)).fetchone() + (change_id, revision), + ).fetchone() else: row = conn.execute( - 'SELECT snoozed_until FROM series WHERE change_id = ?', - (change_id,)).fetchone() + 'SELECT snoozed_until FROM series WHERE change_id = ?', (change_id,) + ).fetchone() return row[0] if row else None -def update_target_branch(conn: sqlite3.Connection, change_id: str, - target_branch: Optional[str], - revision: Optional[int] = None) -> None: +def update_target_branch( + conn: sqlite3.Connection, + change_id: str, + target_branch: Optional[str], + revision: Optional[int] = None, +) -> None: """Set or clear the per-series target branch in the database.""" now = datetime.datetime.now(datetime.timezone.utc).isoformat() if revision is not None: conn.execute( 'UPDATE series SET target_branch = ?, last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (target_branch, now, change_id, revision)) + (target_branch, now, change_id, revision), + ) else: conn.execute( 'UPDATE series SET target_branch = ?, last_activity_at = ?' ' WHERE change_id = ?', - (target_branch, now, change_id)) + (target_branch, now, change_id), + ) conn.commit() -def get_target_branch(conn: sqlite3.Connection, change_id: str, - revision: Optional[int] = None) -> Optional[str]: +def get_target_branch( + conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None +) -> Optional[str]: """Return the per-series target branch, or None.""" if revision is not None: row = conn.execute( 'SELECT target_branch FROM series WHERE change_id = ? AND revision = ?', - (change_id, revision)).fetchone() + (change_id, revision), + ).fetchone() else: row = conn.execute( - 'SELECT target_branch FROM series WHERE change_id = ?', - (change_id,)).fetchone() + 'SELECT target_branch FROM series WHERE change_id = ?', (change_id,) + ).fetchone() return row[0] if row else None @@ -1011,11 +1136,11 @@ def _latest_date_from_msgs(msgs: List[Any]) -> Optional[str]: def update_message_count_from_msgs( - conn: sqlite3.Connection, - change_id: str, - revision: int, - msgs: List[Any], - topdir: Optional[str] = None, + conn: sqlite3.Connection, + change_id: str, + revision: int, + msgs: List[Any], + topdir: Optional[str] = None, ) -> bool: """Update message count and thread blob from already-fetched messages. @@ -1032,9 +1157,9 @@ def update_message_count_from_msgs( last_activity = _latest_date_from_msgs(msgs) row = conn.execute( - 'SELECT message_count FROM series' - ' WHERE change_id = ? AND revision = ?', - (change_id, revision)).fetchone() + 'SELECT message_count FROM series WHERE change_id = ? AND revision = ?', + (change_id, revision), + ).fetchone() existing_count = row['message_count'] if row else None if existing_count is None: @@ -1044,7 +1169,8 @@ def update_message_count_from_msgs( ' SET message_count = ?, seen_message_count = ?,' ' last_update_check = ?, last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (count, count, now, last_activity, change_id, revision)) + (count, count, now, last_activity, change_id, revision), + ) elif count != existing_count: # Count changed — update count but not seen (badge will appear) conn.execute( @@ -1052,13 +1178,15 @@ def update_message_count_from_msgs( ' SET message_count = ?, last_update_check = ?,' ' last_activity_at = COALESCE(?, last_activity_at)' ' WHERE change_id = ? AND revision = ?', - (count, now, last_activity, change_id, revision)) + (count, now, last_activity, change_id, revision), + ) else: # No change — just stamp the check time, skip commit conn.execute( 'UPDATE series SET last_update_check = ?' ' WHERE change_id = ? AND revision = ?', - (now, change_id, revision)) + (now, change_id, revision), + ) conn.commit() return False @@ -1081,7 +1209,9 @@ def fetch_thread_message_count(message_id: str) -> Optional[int]: return len(parsed) -def _fetch_new_since(message_id: str, since: str) -> Optional[Tuple[int, Optional[str]]]: +def _fetch_new_since( + message_id: str, since: str +) -> Optional[Tuple[int, Optional[str]]]: """Fetch new thread messages since a timestamp via LoreNode. Uses LoreNode.get_thread_updates_since() which queries the public-inbox @@ -1103,7 +1233,9 @@ def _fetch_new_since(message_id: str, since: str) -> Optional[Tuple[int, Optiona try: node = b4.get_lore_node() - msgs = node.get_thread_updates_since(message_id, since_dt, strict=False, sort=False) + msgs = node.get_thread_updates_since( + message_id, since_dt, strict=False, sort=False + ) if not msgs: return (0, None) count = len(msgs) @@ -1114,8 +1246,7 @@ def _fetch_new_since(message_id: str, since: str) -> Optional[Tuple[int, Optiona return None -def _store_thread_blob(topdir: str, change_id: str, - msgs: List[Any]) -> Optional[str]: +def _store_thread_blob(topdir: str, change_id: str, msgs: List[Any]) -> Optional[str]: """Serialize msgs to mboxrd and write as a git blob; update tracking commit. Also writes thread-context-blob (the plain-text rendered context for the @@ -1137,9 +1268,9 @@ def _store_thread_blob(topdir: str, change_id: str, logger.debug('No bytes to store for thread blob for %s', change_id) return None - ecode, out = b4.git_run_command(topdir, - ['hash-object', '-w', '--stdin'], - stdin=mbox_bytes) + ecode, out = b4.git_run_command( + topdir, ['hash-object', '-w', '--stdin'], stdin=mbox_bytes + ) if ecode != 0: logger.debug('Could not write thread blob for %s', change_id) return None @@ -1161,13 +1292,16 @@ def _store_thread_blob(topdir: str, change_id: str, cover_subject = series.get('subject', '') patches = tracking.get('patches', []) followup_comments = _parse_msgs_to_followup_comments( - msgs, cover_msgid, patches) + msgs, cover_msgid, patches + ) context_text = _render_thread_context( - followup_comments, patches, cover_subject) + followup_comments, patches, cover_subject + ) if context_text.strip(): ctx_bytes = context_text.encode() ecode2, ctx_out = b4.git_run_command( - topdir, ['hash-object', '-w', '--stdin'], stdin=ctx_bytes) + topdir, ['hash-object', '-w', '--stdin'], stdin=ctx_bytes + ) if ecode2 == 0: ctx_sha = ctx_out.strip() if series.get('thread-context-blob') != ctx_sha: @@ -1175,18 +1309,17 @@ def _store_thread_blob(topdir: str, change_id: str, changed = True if changed: - _b4_review.save_tracking_ref(topdir, branch_name, - cover_text, tracking) + _b4_review.save_tracking_ref(topdir, branch_name, cover_text, tracking) except Exception as ex: - logger.debug('Could not update thread blobs for %s: %s', - change_id, ex) + logger.debug('Could not update thread blobs for %s: %s', change_id, ex) return blob_sha def get_thread_mbox(topdir: str, blob_sha: str) -> Optional[bytes]: """Read cached thread mbox bytes from a git blob; None if unavailable (e.g. GC'd).""" - ecode, out = b4.git_run_command(topdir, ['cat-file', 'blob', blob_sha], - decode=False) + ecode, out = b4.git_run_command( + topdir, ['cat-file', 'blob', blob_sha], decode=False + ) if ecode != 0: logger.debug("Followup blob %s not found (may have been GC'd)", blob_sha) return None @@ -1280,7 +1413,8 @@ def _parse_msgs_to_followup_comments( followup_comments: Dict[int, List[Dict[str, Any]]] = {} for lmsg in sorted(lmbx.followups, key=lambda m: m.date): display_idx = _resolve_patch_for_followup_local( - lmsg.in_reply_to, patch_msgids, lmbx.msgid_map) + lmsg.in_reply_to, patch_msgids, lmbx.msgid_map + ) if display_idx is None: continue mbody = minimised_body_map.get(lmsg.msgid, '').strip() @@ -1300,7 +1434,9 @@ def _parse_msgs_to_followup_comments( 'date': lmsg.date, 'msgid': lmsg.msgid, 'subject': lmsg.subject, - 'depth': _get_followup_depth_local(lmsg.in_reply_to, patch_msgids, lmbx.msgid_map), + 'depth': _get_followup_depth_local( + lmsg.in_reply_to, patch_msgids, lmbx.msgid_map + ), } followup_comments.setdefault(display_idx, []).append(entry) @@ -1326,15 +1462,21 @@ def _render_thread_context( section = f'Follow-up: cover letter ({cover_subject})' else: patch_idx = display_idx - 1 - title = (patches[patch_idx].get('title', f'patch {display_idx}') - if patch_idx < len(patches) else f'patch {display_idx}') + title = ( + patches[patch_idx].get('title', f'patch {display_idx}') + if patch_idx < len(patches) + else f'patch {display_idx}' + ) section = f'Follow-up: patch {display_idx}/{n_patches} — {title}' lines.append(f'== {section} ==') lines.append('') for entry in fc_list: - date_str = (entry['date'].strftime('%Y-%m-%d %H:%M %z') - if entry.get('date') else '') - lines.append(f"From: {entry['fromname']} <{entry['fromemail']}> | {date_str}") + date_str = ( + entry['date'].strftime('%Y-%m-%d %H:%M %z') if entry.get('date') else '' + ) + lines.append( + f'From: {entry["fromname"]} <{entry["fromemail"]}> | {date_str}' + ) lines.append('') lines.append(entry['body'].rstrip()) lines.append('') @@ -1416,9 +1558,9 @@ def render_prior_review_context( return '\n'.join(lines) -def ensure_thread_context_blob(topdir: str, change_id: str, - series: Dict[str, Any], - patches: List[Dict[str, Any]]) -> Optional[str]: +def ensure_thread_context_blob( + topdir: str, change_id: str, series: Dict[str, Any], patches: List[Dict[str, Any]] +) -> Optional[str]: """Ensure thread-context-blob exists in the tracking commit. Migration aid: if thread-blob was written before this feature existed @@ -1454,8 +1596,9 @@ def ensure_thread_context_blob(topdir: str, change_id: str, return None ctx_bytes = context_text.encode() - ecode, out = b4.git_run_command(topdir, ['hash-object', '-w', '--stdin'], - stdin=ctx_bytes) + ecode, out = b4.git_run_command( + topdir, ['hash-object', '-w', '--stdin'], stdin=ctx_bytes + ) if ecode != 0: logger.debug('Could not write thread-context blob for %s', change_id) return None @@ -1469,17 +1612,20 @@ def ensure_thread_context_blob(topdir: str, change_id: str, tracking['series']['thread-context-blob'] = ctx_sha _b4_review.save_tracking_ref(topdir, branch_name, cover_text, tracking) except Exception as ex: - logger.debug('Could not persist thread-context-blob for %s: %s', change_id, ex) + logger.debug( + 'Could not persist thread-context-blob for %s: %s', change_id, ex + ) series['thread-context-blob'] = ctx_sha return ctx_sha -def update_message_counts(identifier: str, - series_list: List[Dict[str, Any]], - topdir: Optional[str] = None, - prefetched: Optional[Dict[Tuple[str, int], List[Any]]] = None, - ) -> Dict[str, int]: +def update_message_counts( + identifier: str, + series_list: List[Dict[str, Any]], + topdir: Optional[str] = None, + prefetched: Optional[Dict[Tuple[str, int], List[Any]]] = None, +) -> Dict[str, int]: """Fetch and store thread message counts for a list of series. For each active series in *series_list* that has a message_id: @@ -1525,7 +1671,8 @@ def update_message_counts(identifier: str, row = conn.execute( 'SELECT message_count, seen_message_count, last_update_check' ' FROM series WHERE change_id = ? AND revision = ?', - (change_id, revision)).fetchone() + (change_id, revision), + ).fetchone() existing_count = row['message_count'] if row else None last_check = row['last_update_check'] if row else None @@ -1543,7 +1690,8 @@ def update_message_counts(identifier: str, ' SET message_count = ?, seen_message_count = ?,' ' last_update_check = ?, last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (count, count, now, last_activity, change_id, revision)) + (count, count, now, last_activity, change_id, revision), + ) conn.commit() updated += 1 if topdir and pre_msgs: @@ -1561,7 +1709,8 @@ def update_message_counts(identifier: str, ' SET message_count = ?, seen_message_count = ?,' ' last_update_check = ?, last_activity_at = ?' ' WHERE change_id = ? AND revision = ?', - (count, count, now, last_activity, change_id, revision)) + (count, count, now, last_activity, change_id, revision), + ) conn.commit() updated += 1 if topdir and parsed: @@ -1580,7 +1729,8 @@ def update_message_counts(identifier: str, ' SET message_count = message_count + ?, last_update_check = ?,' ' last_activity_at = COALESCE(?, last_activity_at)' ' WHERE change_id = ? AND revision = ?', - (new_count, now, new_activity, change_id, revision)) + (new_count, now, new_activity, change_id, revision), + ) conn.commit() updated += 1 if topdir: @@ -1594,18 +1744,21 @@ def update_message_counts(identifier: str, return {'updated': updated, 'errors': errors} -def mark_all_messages_seen(conn: sqlite3.Connection, change_id: str, - revision: int) -> None: +def mark_all_messages_seen( + conn: sqlite3.Connection, change_id: str, revision: int +) -> None: """Set seen_message_count = message_count, clearing the unread badge.""" conn.execute( 'UPDATE series SET seen_message_count = message_count' ' WHERE change_id = ? AND revision = ?', - (change_id, revision)) + (change_id, revision), + ) conn.commit() -def sync_seen_from_unseen_count(identifier: str, change_id: str, - revision: int, unseen_count: int) -> bool: +def sync_seen_from_unseen_count( + identifier: str, change_id: str, revision: int, unseen_count: int +) -> bool: """Sync seen_message_count so the unread badge matches the messages DB. Sets ``seen_message_count = message_count - unseen_count``, clamped @@ -1621,7 +1774,8 @@ def sync_seen_from_unseen_count(identifier: str, change_id: str, row = conn.execute( 'SELECT message_count, seen_message_count FROM series' ' WHERE change_id = ? AND revision = ?', - (change_id, revision)).fetchone() + (change_id, revision), + ).fetchone() if row is None: conn.close() return False @@ -1637,16 +1791,17 @@ def sync_seen_from_unseen_count(identifier: str, change_id: str, return False conn.execute( - 'UPDATE series SET seen_message_count = ?' - ' WHERE change_id = ? AND revision = ?', - (new_seen, change_id, revision)) + 'UPDATE series SET seen_message_count = ? WHERE change_id = ? AND revision = ?', + (new_seen, change_id, revision), + ) conn.commit() conn.close() return True -def refresh_message_count(identifier: str, change_id: str, revision: int, - total_messages: int) -> bool: +def refresh_message_count( + identifier: str, change_id: str, revision: int, total_messages: int +) -> bool: """Opportunistically refresh the message count from already-fetched messages. Called when thread messages have been fetched for another purpose (e.g. @@ -1673,7 +1828,8 @@ def refresh_message_count(identifier: str, change_id: str, revision: int, row = conn.execute( 'SELECT message_count, seen_message_count FROM series' ' WHERE change_id = ? AND revision = ?', - (change_id, revision)).fetchone() + (change_id, revision), + ).fetchone() if row is None: conn.close() return False @@ -1692,7 +1848,8 @@ def refresh_message_count(identifier: str, change_id: str, revision: int, 'UPDATE series SET message_count = ?, seen_message_count = ?,' ' last_update_check = ?' ' WHERE change_id = ? AND revision = ?', - (count, count, now, change_id, revision)) + (count, count, now, change_id, revision), + ) else: # Count changed: update only message_count; cap seen if it # exceeds the new count (possible when dedup reduces the total). @@ -1702,20 +1859,23 @@ def refresh_message_count(identifier: str, change_id: str, revision: int, 'UPDATE series SET message_count = ?, seen_message_count = ?,' ' last_update_check = ?' ' WHERE change_id = ? AND revision = ?', - (count, count, now, change_id, revision)) + (count, count, now, change_id, revision), + ) else: conn.execute( 'UPDATE series SET message_count = ?, last_update_check = ?' ' WHERE change_id = ? AND revision = ?', - (count, now, change_id, revision)) + (count, now, change_id, revision), + ) conn.commit() conn.close() return True -def rescan_branches(identifier: str, topdir: str, - branch: Optional[str] = None) -> Dict[str, int]: +def rescan_branches( + identifier: str, topdir: str, branch: Optional[str] = None +) -> Dict[str, int]: """Rescan review branches and sync status/metadata into the tracking DB. Iterates b4/review/* branches (or a single branch if specified). For each @@ -1757,7 +1917,8 @@ def rescan_branches(identifier: str, topdir: str, stored = conn.execute( 'SELECT branch_sha FROM series WHERE change_id = ?' ' ORDER BY revision DESC LIMIT 1', - (change_id_from_branch,)).fetchone() + (change_id_from_branch,), + ).fetchone() if stored and stored['branch_sha'] == current_sha: # Branch HEAD unchanged — skip the expensive tracking-commit read. scanned_change_ids.add(change_id_from_branch) @@ -1775,8 +1936,12 @@ def rescan_branches(identifier: str, topdir: str, # Verify identifier matches (skip if mismatch) if track_id_value and track_id_value != identifier: - logger.warning('Branch %s has identifier %s, expected %s; skipping', - br, track_id_value, identifier) + logger.warning( + 'Branch %s has identifier %s, expected %s; skipping', + br, + track_id_value, + identifier, + ) continue change_id = series.get('change-id') @@ -1803,23 +1968,28 @@ def rescan_branches(identifier: str, topdir: str, # Upsert metadata and sync status from the tracking commit. tracked_at = series.get('tracked-at') - add_series_to_db(conn, change_id, - revision=revision, - subject=series.get('subject'), - sender_name=series.get('fromname'), - sender_email=series.get('fromemail'), - sent_at=sent_at, - message_id=message_id, - num_patches=series.get('expected', 0), - added_at=tracked_at, - is_rethreaded=is_rethreaded) + add_series_to_db( + conn, + change_id, + revision=revision, + subject=series.get('subject'), + sender_name=series.get('fromname'), + sender_email=series.get('fromemail'), + sent_at=sent_at, + message_id=message_id, + num_patches=series.get('expected', 0), + added_at=tracked_at, + is_rethreaded=is_rethreaded, + ) update_series_status(conn, change_id, status, revision=revision) # Rebuild series_patches from tracking commit data tracking_patches = tracking.get('patches', []) if tracking_patches: - conn.execute('DELETE FROM series_patches WHERE change_id = ? AND revision = ?', - (change_id, revision)) + conn.execute( + 'DELETE FROM series_patches WHERE change_id = ? AND revision = ?', + (change_id, revision), + ) rows = [] for i, p in enumerate(tracking_patches, 1): p_msgid = p.get('header-info', {}).get('msgid', '') @@ -1828,12 +1998,16 @@ def rescan_branches(identifier: str, topdir: str, if rows: conn.executemany( 'INSERT INTO series_patches (change_id, revision, position, message_id, subject)' - ' VALUES (?, ?, ?, ?, ?)', rows) + ' VALUES (?, ?, ?, ?, ?)', + rows, + ) conn.commit() # Persist the new HEAD SHA so future rescans can skip this branch. - conn.execute('UPDATE series SET branch_sha = ? WHERE change_id = ? AND revision = ?', - (current_sha, change_id, revision)) + conn.execute( + 'UPDATE series SET branch_sha = ? WHERE change_id = ? AND revision = ?', + (current_sha, change_id, revision), + ) conn.commit() logger.info('Rescanned: %s (status: %s)', change_id, status) @@ -1848,12 +2022,10 @@ def rescan_branches(identifier: str, topdir: str, sid = s.get('change_id') if not sid: continue - if (s.get('status') in active_statuses - and sid not in scanned_change_ids): + if s.get('status') in active_statuses and sid not in scanned_change_ids: branch_name = f'b4/review/{sid}' if not b4.git_branch_exists(topdir, branch_name): - update_series_status(conn, sid, 'gone', - revision=s.get('revision')) + update_series_status(conn, sid, 'gone', revision=s.get('revision')) logger.info('Marked as gone: %s', sid) gone += 1 @@ -1861,9 +2033,9 @@ def rescan_branches(identifier: str, topdir: str, return {'gone': gone, 'changed': changed} - -def delete_series(conn: sqlite3.Connection, change_id: str, - revision: Optional[int] = None) -> None: +def delete_series( + conn: sqlite3.Connection, change_id: str, revision: Optional[int] = None +) -> None: """Delete a series from the database. When *revision* is given only that specific revision is removed; @@ -1871,10 +2043,14 @@ def delete_series(conn: sqlite3.Connection, change_id: str, behaviour kept for backwards compatibility). """ if revision is not None: - conn.execute('DELETE FROM revisions WHERE change_id = ? AND revision = ?', - (change_id, revision)) - conn.execute('DELETE FROM series WHERE change_id = ? AND revision = ?', - (change_id, revision)) + conn.execute( + 'DELETE FROM revisions WHERE change_id = ? AND revision = ?', + (change_id, revision), + ) + conn.execute( + 'DELETE FROM series WHERE change_id = ? AND revision = ?', + (change_id, revision), + ) else: conn.execute('DELETE FROM revisions WHERE change_id = ?', (change_id,)) conn.execute('DELETE FROM series WHERE change_id = ?', (change_id,)) diff --git a/src/b4/review_tui/__init__.py b/src/b4/review_tui/__init__.py index 68548e6..27551d0 100644 --- a/src/b4/review_tui/__init__.py +++ b/src/b4/review_tui/__init__.py @@ -18,10 +18,18 @@ from b4.review_tui._review_app import ReviewApp from b4.review_tui._tracking_app import TrackingApp __all__ = [ - 'logger', 'PATCH_STATE_MARKERS', - 'resolve_styles', 'reviewer_colours', + 'logger', + 'PATCH_STATE_MARKERS', + 'resolve_styles', + 'reviewer_colours', 'gather_attestation_info', - '_addrs_to_lines', '_lines_to_header', '_validate_addrs', - 'ReviewApp', 'TrackingApp', 'PwApp', - 'run_branch_tui', 'run_pw_tui', 'run_tracking_tui', + '_addrs_to_lines', + '_lines_to_header', + '_validate_addrs', + 'ReviewApp', + 'TrackingApp', + 'PwApp', + 'run_branch_tui', + 'run_pw_tui', + 'run_tracking_tui', ] diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py index e819af5..c6d5df3 100644 --- a/src/b4/review_tui/_common.py +++ b/src/b4/review_tui/_common.py @@ -109,11 +109,11 @@ def get_thread_msgs( # Per-patch state indicators — same glyphs as _tracking_app._STATUS_SYMBOLS PATCH_STATE_MARKERS: Dict[str, str] = { - '': ' ', - 'external': '\u00b1', # ± plus-minus (= external comments available) - 'draft': '\u270e', # ✎ pencil (= maintainer reviewing) - 'done': '\u2713', # ✓ check (= done) - 'skip': '\u2715', # ✕ cross (= skipped) + '': ' ', + 'external': '\u00b1', # ± plus-minus (= external comments available) + 'draft': '\u270e', # ✎ pencil (= maintainer reviewing) + 'done': '\u2713', # ✓ check (= done) + 'skip': '\u2715', # ✕ cross (= skipped) 'unchanged': '\u2261', # ≡ identical-to (= patch unchanged from prior revision) } @@ -126,8 +126,6 @@ CI_CHECK_LABELS = { } - - class CheckRunnerMixin: """Mixin providing CI check execution for Textual App subclasses. @@ -165,31 +163,44 @@ class CheckRunnerMixin: self.notify('No message-id for this series', severity='error') # type: ignore[attr-defined] return from b4.review_tui._modals import CheckLoadingScreen + self._check_loading = CheckLoadingScreen() self.push_screen(self._check_loading) # type: ignore[attr-defined] self.run_worker( # type: ignore[attr-defined] - lambda: self._fetch_and_check(message_id, series_subject, - change_id=change_id, force=force), - name='_check_worker', thread=True) + lambda: self._fetch_and_check( + message_id, series_subject, change_id=change_id, force=force + ), + name='_check_worker', + thread=True, + ) def _dismiss_loading(self, msg: str = '', severity: str = '') -> None: """Dismiss the check loading screen and optionally notify.""" + def _do() -> None: if self._check_loading is not None and self._check_loading.is_attached: self._check_loading.dismiss(None) if msg: self.notify(msg, severity=severity) # type: ignore[attr-defined] + self.app.call_from_thread(_do) # type: ignore[attr-defined] def _update_loading(self, text: str) -> None: """Update the loading screen status text.""" + def _do() -> None: if self._check_loading is not None and self._check_loading.is_attached: self._check_loading.update_status(text) + self.app.call_from_thread(_do) # type: ignore[attr-defined] - def _fetch_and_check(self, message_id: str, series_subject: str, - change_id: str = '', force: bool = False) -> None: + def _fetch_and_check( + self, + message_id: str, + series_subject: str, + change_id: str = '', + force: bool = False, + ) -> None: """Fetch thread, run checks, and push results modal (worker thread).""" import b4.review.checks as checks from b4.review_tui._modals import TrackingCheckResultsScreen @@ -219,7 +230,9 @@ class CheckRunnerMixin: try: _cover, tracking = b4.review.load_tracking(topdir, review_branch) blob_sha = tracking.get('series', {}).get('thread-blob', '') - fd, tracking_file = tempfile.mkstemp(prefix='b4-tracking-', suffix='.json') + fd, tracking_file = tempfile.mkstemp( + prefix='b4-tracking-', suffix='.json' + ) with os.fdopen(fd, 'w') as fp: json.dump(tracking, fp, indent=2) extra_env['B4_TRACKING_FILE'] = tracking_file @@ -229,8 +242,7 @@ class CheckRunnerMixin: # Fetch the thread (local blob first, then lore) self._update_loading('Loading thread\u2026') with _quiet_worker(): - msgs = get_thread_msgs(topdir, message_id, - blob_sha=blob_sha, quiet=True) + msgs = get_thread_msgs(topdir, message_id, blob_sha=blob_sha, quiet=True) if not msgs: self._dismiss_loading('Could not fetch thread from lore', 'error') return @@ -272,7 +284,9 @@ class CheckRunnerMixin: ordered_msgs: List[Tuple[str, email.message.EmailMessage]] = [] if cover_msg: patch_labels.append(f'0/{num_patches}') - patch_subjects.append(b4.LoreSubject(cover_msg[1].get('subject', '')).subject) + patch_subjects.append( + b4.LoreSubject(cover_msg[1].get('subject', '')).subject + ) ordered_msgs.append(cover_msg) for idx, (mid, msg) in enumerate(patches, 1): patch_labels.append(f'{idx}/{num_patches}') @@ -313,8 +327,13 @@ class CheckRunnerMixin: label = patch_labels[pidx] self._update_loading(f'Running checks\u2026 {label}') single_results = checks.run_perpatch_checks( - [(mid, _msg)], perpatch_cmds, topdir, pwkey, pwurl, - extra_env=extra_env) + [(mid, _msg)], + perpatch_cmds, + topdir, + pwkey, + pwurl, + extra_env=extra_env, + ) for result in single_results.get(mid, []): tool = result['tool'] all_tools.add(tool) @@ -323,12 +342,14 @@ class CheckRunnerMixin: # Run per-series checks (only if not cached) if series_cmds: - target = cover_msg if cover_msg else (ordered_msgs[0] if ordered_msgs else None) + target = ( + cover_msg if cover_msg else (ordered_msgs[0] if ordered_msgs else None) + ) if target and target[0] not in cached: self._update_loading('Running series checks\u2026') series_results = checks.run_series_checks( - target, series_cmds, topdir, pwkey, pwurl, - extra_env=extra_env) + target, series_cmds, topdir, pwkey, pwurl, extra_env=extra_env + ) cover_idx = 0 for result in series_results: tool = result['tool'] @@ -359,9 +380,12 @@ class CheckRunnerMixin: def _push_modal() -> None: if self._check_loading is not None and self._check_loading.is_attached: self._check_loading.dismiss(None) - self.push_screen(TrackingCheckResultsScreen( # type: ignore[attr-defined] - title, patch_labels, patch_subjects, tools_sorted, matrix), - callback=_on_result) + self.push_screen( # type: ignore[attr-defined] + TrackingCheckResultsScreen( + title, patch_labels, patch_subjects, tools_sorted, matrix + ), + callback=_on_result, + ) self.app.call_from_thread(_push_modal) # type: ignore[attr-defined] @@ -391,7 +415,10 @@ def _make_initials(name: str) -> str: def _has_review_data(reviews: Dict[str, Dict[str, Any]]) -> bool: """Return True if any reviewer has trailers, reply, comments, or a note.""" return any( - r.get('trailers') or r.get('reply', '') or r.get('comments') or r.get('note', '') + r.get('trailers') + or r.get('reply', '') + or r.get('comments') + or r.get('note', '') for r in reviews.values() ) @@ -423,11 +450,13 @@ def _strip_attribution(body: str) -> str: if attr_end is None: return body # Check that the next non-blank line starts with '>' - for ln in lines[attr_end + 1:]: + for ln in lines[attr_end + 1 :]: if ln.strip(): if ln.startswith('> ') or ln.strip() == '>': - remaining = lines[attr_end + 1:] - while remaining and (not remaining[0].strip() or remaining[0].strip() == '>'): + remaining = lines[attr_end + 1 :] + while remaining and ( + not remaining[0].strip() or remaining[0].strip() == '>' + ): remaining.pop(0) return '\n'.join(remaining) break @@ -452,10 +481,17 @@ def _write_followup_comments( """ if not fc_list: return - rev_palette = reviewer_colours(ts) if ts else [ - 'dark_goldenrod', 'dark_cyan', - 'dark_magenta', 'dark_red', 'dark_blue', - ] + rev_palette = ( + reviewer_colours(ts) + if ts + else [ + 'dark_goldenrod', + 'dark_cyan', + 'dark_magenta', + 'dark_red', + 'dark_blue', + ] + ) fc_emails = sorted({e['fromemail'] for e in fc_list}) colour_map: Dict[str, str] = {} for ci, em in enumerate(fc_emails): @@ -473,7 +509,9 @@ def _write_followup_comments( body = _strip_attribution(e['body']) body_text = Text() body_text.append(f'From: {fromname} <{e["fromemail"]}>\n', style='bold') - body_text.append(f'Date: {e["date"].strftime("%Y-%m-%d %H:%M %z")}\n', style='bold') + body_text.append( + f'Date: {e["date"].strftime("%Y-%m-%d %H:%M %z")}\n', style='bold' + ) if msgid := e.get('msgid', ''): body_text.append(f'Msgid: <{msgid}>\n', style='bold') body_text.append('\n') @@ -590,7 +628,7 @@ def _write_comments( the same diff line are rendered as separate panels. *ts* is a resolved theme styles dict from :func:`resolve_styles`. """ - bg = f"on {ts['panel']}" if ts else 'on grey11' + bg = f'on {ts["panel"]}' if ts else 'on grey11' for name, colour, text in entries: panel = Panel( Text(text), @@ -648,7 +686,8 @@ def _write_followup_trailers( def _write_diff_line( - viewer: 'RichLog', line: str, + viewer: 'RichLog', + line: str, ts: Optional[Dict[str, str]] = None, ) -> None: """Write a single diff line to a RichLog with appropriate colouring. @@ -658,7 +697,7 @@ def _write_diff_line( if line.startswith(('diff --git ', '--- ', '+++ ')): viewer.write(Text(line, style='bold')) elif line.startswith('@@'): - viewer.write(Text(line, style=f"bold {ts['accent']}" if ts else 'bold cyan')) + viewer.write(Text(line, style=f'bold {ts["accent"]}' if ts else 'bold cyan')) elif line.startswith('+'): viewer.write(Text(line, style=ts['success'] if ts else 'green')) elif line.startswith('-'): @@ -668,7 +707,8 @@ def _write_diff_line( def _render_email_to_viewer( - viewer: 'RichLog', msg: email.message.EmailMessage, + viewer: 'RichLog', + msg: email.message.EmailMessage, ts: Optional[Dict[str, str]] = None, ) -> None: """Render an EmailMessage into a RichLog, headers first then body. @@ -684,14 +724,15 @@ def _render_email_to_viewer( continue val = str(val) if hdr.lower() in ('to', 'cc'): - wrapped = b4.LoreMessage.wrap_header( - (hdr, val), transform='decode').decode(errors='replace') + wrapped = b4.LoreMessage.wrap_header((hdr, val), transform='decode').decode( + errors='replace' + ) first_line, *rest = wrapped.splitlines() colon = first_line.find(':') hdr_text = Text() if colon >= 0: - hdr_text.append(first_line[:colon + 1], style='bold') - hdr_text.append(first_line[colon + 1:]) + hdr_text.append(first_line[: colon + 1], style='bold') + hdr_text.append(first_line[colon + 1 :]) else: hdr_text.append(first_line) for r in rest: @@ -705,10 +746,14 @@ def _render_email_to_viewer( viewer.write(hdr_text) viewer.write('') payload = msg.get_payload(decode=True) - body = payload.decode(errors='replace') if isinstance(payload, bytes) else str(payload or '') + body = ( + payload.decode(errors='replace') + if isinstance(payload, bytes) + else str(payload or '') + ) for line in body.splitlines(): if line.startswith('>'): - viewer.write(Text(line, style=f"dim {ts['accent']}" if ts else 'dim cyan')) + viewer.write(Text(line, style=f'dim {ts["accent"]}' if ts else 'dim cyan')) elif line.startswith('---'): viewer.write(Text(line, style='dim')) else: @@ -764,9 +809,11 @@ def gather_attestation_info(lser: b4.LoreSeries) -> Dict[str, Any]: check_at = 'HEAD' try: - apply_checked, mismatches = lser.check_applies_clean(topdir, at=check_at) + apply_checked, mismatches = lser.check_applies_clean( + topdir, at=check_at + ) apply_mismatches = len(mismatches) - applies_clean = (apply_mismatches == 0) + applies_clean = apply_mismatches == 0 except Exception: pass @@ -802,15 +849,25 @@ def gather_attestation_info(lser: b4.LoreSeries) -> Dict[str, Any]: patch_idx = f'{idx:0{width}d}/{total:0{width}d}' if lmsg is None: - per_patch.append({ - 'index': patch_idx, - 'passing': False, - 'attestations': [{'status': 'missing', 'identity': 'Patch not available', 'passing': False}], - }) + per_patch.append( + { + 'index': patch_idx, + 'passing': False, + 'attestations': [ + { + 'status': 'missing', + 'identity': 'Patch not available', + 'passing': False, + } + ], + } + ) same_attestation = False continue - attestations, overall_passing, critical = lmsg.get_attestation_status(attpolicy, maxdays) + attestations, overall_passing, critical = lmsg.get_attestation_status( + attpolicy, maxdays + ) if critical: any_critical = True @@ -826,11 +883,13 @@ def gather_attestation_info(lser: b4.LoreSeries) -> Dict[str, Any]: if ref_ids != cur_ids: same_attestation = False - per_patch.append({ - 'index': patch_idx, - 'passing': overall_passing, - 'attestations': attestations, - }) + per_patch.append( + { + 'index': patch_idx, + 'passing': overall_passing, + 'attestations': attestations, + } + ) return { 'total': len(per_patch), @@ -845,5 +904,3 @@ def gather_attestation_info(lser: b4.LoreSeries) -> Dict[str, Any]: 'apply_checked': apply_checked, 'apply_mismatches': apply_mismatches, } - - diff --git a/src/b4/review_tui/_entry.py b/src/b4/review_tui/_entry.py index 68a48af..ece97da 100644 --- a/src/b4/review_tui/_entry.py +++ b/src/b4/review_tui/_entry.py @@ -25,12 +25,17 @@ def _tui_use_mouse() -> bool: return True -def run_pw_tui(pwkey: str, pwurl: str, pwproj: str, - email_dryrun: bool = False, - patatt_sign: bool = True) -> None: +def run_pw_tui( + pwkey: str, + pwurl: str, + pwproj: str, + email_dryrun: bool = False, + patatt_sign: bool = True, +) -> None: """Launch the Patchwork series browser TUI.""" - app = PwApp(pwkey, pwurl, pwproj, - email_dryrun=email_dryrun, patatt_sign=patatt_sign) + app = PwApp( + pwkey, pwurl, pwproj, email_dryrun=email_dryrun, patatt_sign=patatt_sign + ) app.run(mouse=_tui_use_mouse()) @@ -40,9 +45,12 @@ def run_branch_tui(session: Dict[str, Any]) -> None: app.run(mouse=_tui_use_mouse()) -def run_tracking_tui(identifier: str, email_dryrun: bool = False, - no_sign: bool = False, - no_mouse: bool = False) -> None: +def run_tracking_tui( + identifier: str, + email_dryrun: bool = False, + no_sign: bool = False, + no_mouse: bool = False, +) -> None: """Entry point called from b4.review.cmd_tui(). Loops between TrackingApp and ReviewApp as needed. @@ -88,14 +96,21 @@ def run_tracking_tui(identifier: str, email_dryrun: bool = False, review_app = ReviewApp(session) review_app.run(mouse=use_mouse) except SystemExit: - logger.warning('Could not prepare review session for branch: %s', original_branch) + logger.warning( + 'Could not prepare review session for branch: %s', original_branch + ) return # Normal tracking mode - loop between TrackingApp and ReviewApp focus_change_id: Optional[str] = None while True: - app = TrackingApp(identifier, original_branch, focus_change_id=focus_change_id, - email_dryrun=email_dryrun, patatt_sign=patatt_sign) + app = TrackingApp( + identifier, + original_branch, + focus_change_id=focus_change_id, + email_dryrun=email_dryrun, + patatt_sign=patatt_sign, + ) focus_change_id = None branch_name = app.run(mouse=use_mouse) @@ -110,9 +125,13 @@ def run_tracking_tui(identifier: str, email_dryrun: bool = False, pwurl = str(config.get('pw-url', '')) pwproj = str(config.get('pw-project', '')) if pwkey and pwurl and pwproj: - run_pw_tui(pwkey, pwurl, pwproj, - email_dryrun=email_dryrun, - patatt_sign=patatt_sign) + run_pw_tui( + pwkey, + pwurl, + pwproj, + email_dryrun=email_dryrun, + patatt_sign=patatt_sign, + ) continue # User selected a branch to review - prepare session and run ReviewApp @@ -121,7 +140,9 @@ def run_tracking_tui(identifier: str, email_dryrun: bool = False, session = b4.review._prepare_review_session(cmdargs) except SystemExit: # Session prep failed (e.g., branch doesn't exist) - logger.warning('Could not prepare review session for branch: %s', branch_name) + logger.warning( + 'Could not prepare review session for branch: %s', branch_name + ) continue session['email_dryrun'] = email_dryrun @@ -143,7 +164,8 @@ def run_tracking_tui(identifier: str, email_dryrun: bool = False, if tracking_status and focus_change_id: conn = b4.review.tracking.get_db(identifier) b4.review.tracking.update_series_status( - conn, focus_change_id, tracking_status, revision=revision) + conn, focus_change_id, tracking_status, revision=revision + ) conn.close() except Exception as ex: logger.warning('Could not sync tracking status: %s', ex) @@ -155,7 +177,13 @@ def run_tracking_tui(identifier: str, email_dryrun: bool = False, if original_branch: current = b4.git_get_current_branch(topdir) if current and current != original_branch: - logger.info('Checking out %s and starting tracking UI...', original_branch) - ecode, _out = b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True) + logger.info( + 'Checking out %s and starting tracking UI...', original_branch + ) + ecode, _out = b4.git_run_command( + topdir, ['checkout', original_branch], logstderr=True + ) if ecode != 0: - logger.warning('Could not restore original branch: %s', original_branch) + logger.warning( + 'Could not restore original branch: %s', original_branch + ) diff --git a/src/b4/review_tui/_lite_app.py b/src/b4/review_tui/_lite_app.py index 7a37e0d..801665b 100644 --- a/src/b4/review_tui/_lite_app.py +++ b/src/b4/review_tui/_lite_app.py @@ -34,6 +34,7 @@ from b4.review_tui._modals import FollowupReplyPreviewScreen @dataclass class ThreadNode: """A single message in the thread tree.""" + lmsg: b4.LoreMessage children: List['ThreadNode'] = field(default_factory=list) depth: int = 0 @@ -54,7 +55,7 @@ def _flatten_tree( """DFS-flatten a list of roots into a list with tree_art set.""" result: List[ThreadNode] = [] for i, node in enumerate(roots): - is_last = (i == len(roots) - 1) + is_last = i == len(roots) - 1 if is_root: node.tree_art = '' else: @@ -88,7 +89,9 @@ def build_thread_tree(lmbx: b4.LoreMailbox) -> List[ThreadNode]: att_list: List[Dict[str, Any]] = [] att_passing = True if attpolicy != 'off': - att_list, att_passing, _critical = lmsg.get_attestation_status(attpolicy, maxdays) + att_list, att_passing, _critical = lmsg.get_attestation_status( + attpolicy, maxdays + ) nodes[msgid] = ThreadNode( lmsg=lmsg, is_patch=lmsg.has_diff, @@ -121,8 +124,7 @@ def build_thread_tree(lmbx: b4.LoreMailbox) -> List[ThreadNode]: return flat -def _build_thread_label(node: ThreadNode, - ts: Optional[Dict[str, str]] = None) -> Text: +def _build_thread_label(node: ThreadNode, ts: Optional[Dict[str, str]] = None) -> Text: """Build the Text label for a thread index row.""" lmsg = node.lmsg if lmsg.date: @@ -136,15 +138,15 @@ def _build_thread_label(node: ThreadNode, author += '\u2026' author = pad_display(author, 20) is_unseen = node.is_unseen - unseen_style = f"bold {ts['warning']}" if ts else 'bold' - flag_style = f"bold {ts['accent']}" if ts else 'bold' + unseen_style = f'bold {ts["warning"]}' if ts else 'bold' + flag_style = f'bold {ts["accent"]}' if ts else 'bold' answered_style = ts['success'] if ts else '' if node.is_answered: - row_style = f"dim {ts['success']}" if ts else 'dim' + row_style = f'dim {ts["success"]}' if ts else 'dim' elif is_unseen: row_style = '' elif node.is_flagged: - row_style = f"bold {ts['accent']}" if ts else 'bold' + row_style = f'bold {ts["accent"]}' if ts else 'bold' else: row_style = 'dim' text = Text(no_wrap=True, overflow='ellipsis') @@ -226,9 +228,16 @@ class MessageViewScreen(ModalScreen[None]): def compose(self) -> ComposeResult: with Vertical(id='msg-dialog'): - yield Static(f'Subject: {self._node.lmsg.full_subject}', id='msg-title', markup=False) - yield RichLog(id='msg-viewer', highlight=False, wrap=True, - markup=False, auto_scroll=False) + yield Static( + f'Subject: {self._node.lmsg.full_subject}', id='msg-title', markup=False + ) + yield RichLog( + id='msg-viewer', + highlight=False, + wrap=True, + markup=False, + auto_scroll=False, + ) yield Static( 'r reply | F flag | S skip quoted | j/k prev/next msg | q back', id='msg-hint', @@ -246,7 +255,7 @@ class MessageViewScreen(ModalScreen[None]): if self._node.is_flagged: ts = resolve_styles(self.app) text = Text() - text.append(f'Subject: {subject} \u2605', style=f"bold {ts['accent']}") + text.append(f'Subject: {subject} \u2605', style=f'bold {ts["accent"]}') title.update(text) else: title.update(f'Subject: {subject}') @@ -286,7 +295,7 @@ class MessageViewScreen(ModalScreen[None]): linkurl = linkmask % lmsg.msgid hdr_text = Text() hdr_text.append('Link: ', style='dim bold') - hdr_text.append(linkurl, style=f"dim link {linkurl}") + hdr_text.append(linkurl, style=f'dim link {linkurl}') viewer.write(hdr_text) # Attestation status @@ -302,14 +311,20 @@ class MessageViewScreen(ModalScreen[None]): if att.get('passing'): att_text.append(f'\u2713 {identity}', style=ts['success']) if 'mismatch' in att: - att_text.append(f' (From: {att["mismatch"]})', style=ts['warning']) + att_text.append( + f' (From: {att["mismatch"]})', style=ts['warning'] + ) else: if status == 'badsig': att_text.append(f'\u2717 BADSIG: {identity}', style=ts['error']) elif status == 'nokey': - att_text.append(f'\u2717 No key: {identity}', style=ts['warning']) + att_text.append( + f'\u2717 No key: {identity}', style=ts['warning'] + ) else: - att_text.append(f'\u2717 {status}: {identity}', style=ts['error']) + att_text.append( + f'\u2717 {status}: {identity}', style=ts['error'] + ) viewer.write(att_text) viewer.write('') @@ -323,7 +338,7 @@ class MessageViewScreen(ModalScreen[None]): if in_diff: _write_diff_line(viewer, line, ts=ts) elif line.startswith('>'): - viewer.write(Text(line, style=f"dim {ts['accent']}")) + viewer.write(Text(line, style=f'dim {ts["accent"]}')) elif line.startswith('---'): viewer.write(Text(line, style='dim')) else: @@ -331,8 +346,10 @@ class MessageViewScreen(ModalScreen[None]): @staticmethod def _write_addr_header( - viewer: RichLog, hdr_name: str, - pairs: List[Any], width: int, + viewer: RichLog, + hdr_name: str, + pairs: List[Any], + width: int, ) -> None: """Write an address header, packing addresses to fill each line.""" indent_len = len(hdr_name) + 2 # "Cc: " @@ -561,6 +578,7 @@ class LiteThreadScreen(ModalScreen[None]): return try: from b4.review import messages + conn = messages.get_db() msgids = [n.lmsg.msgid for n in self._thread_nodes if n.lmsg.msgid] flags_map = messages.get_flags_bulk(conn, msgids) @@ -597,6 +615,7 @@ class LiteThreadScreen(ModalScreen[None]): return try: from b4.review import messages + conn = messages.get_db() messages.set_flag(conn, msgid, 'Seen', self._msg_date(node)) conn.close() @@ -611,6 +630,7 @@ class LiteThreadScreen(ModalScreen[None]): return try: from b4.review import messages + conn = messages.get_db() messages.set_flag(conn, msgid, 'Answered', self._msg_date(node)) conn.close() @@ -644,30 +664,34 @@ class LiteThreadScreen(ModalScreen[None]): # Maintainer's own message → Seen if node.is_unseen: node.is_unseen = False - seen_entries.append({'msgid': node.lmsg.msgid, - 'msg_date': self._msg_date(node)}) + seen_entries.append( + {'msgid': node.lmsg.msgid, 'msg_date': self._msg_date(node)} + ) # Immediate parent → Answered parent_id = node.lmsg.in_reply_to if parent_id and parent_id in node_map: parent = node_map[parent_id] if not parent.is_answered: parent.is_answered = True - answered_entries.append({'msgid': parent_id, - 'msg_date': self._msg_date(parent)}) + answered_entries.append( + {'msgid': parent_id, 'msg_date': self._msg_date(parent)} + ) # All ancestors → Seen ancestor_id = node.lmsg.in_reply_to while ancestor_id and ancestor_id in node_map: ancestor = node_map[ancestor_id] if ancestor.is_unseen: ancestor.is_unseen = False - seen_entries.append({'msgid': ancestor_id, - 'msg_date': self._msg_date(ancestor)}) + seen_entries.append( + {'msgid': ancestor_id, 'msg_date': self._msg_date(ancestor)} + ) ancestor_id = ancestor.lmsg.in_reply_to if not seen_entries and not answered_entries: return try: from b4.review import messages + conn = messages.get_db() if seen_entries: messages.set_flags_bulk(conn, seen_entries, 'Seen') @@ -685,6 +709,7 @@ class LiteThreadScreen(ModalScreen[None]): return try: from b4.review import messages + conn = messages.get_db() if node.is_flagged: messages.set_flag(conn, msgid, 'Flagged', self._msg_date(node)) @@ -725,7 +750,9 @@ class LiteThreadScreen(ModalScreen[None]): for item in lv.query(ThreadIndexItem): item.query_one(Label).update(_build_thread_label(item.node, ts)) - def compose_reply(self, node: ThreadNode, initial_text: Optional[str] = None) -> None: + def compose_reply( + self, node: ThreadNode, initial_text: Optional[str] = None + ) -> None: """Compose a reply to the given thread node using external editor.""" lmsg = node.lmsg if initial_text is not None: @@ -768,10 +795,15 @@ class LiteThreadScreen(ModalScreen[None]): try: with self.app.suspend(): smtp, fromaddr = b4.get_smtp(dryrun=self._email_dryrun) - sent = b4.send_mail(smtp, [msg], fromaddr=fromaddr, - patatt_sign=self._patatt_sign, - dryrun=self._email_dryrun, - output_dir=None, reflect=False) + sent = b4.send_mail( + smtp, + [msg], + fromaddr=fromaddr, + patatt_sign=self._patatt_sign, + dryrun=self._email_dryrun, + output_dir=None, + reflect=False, + ) if sent is None: self.app.notify('Failed to send reply.', severity='error') elif self._email_dryrun: diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py index 412419a..c8521d6 100644 --- a/src/b4/review_tui/_modals.py +++ b/src/b4/review_tui/_modals.py @@ -132,7 +132,10 @@ class TrailerScreen(JKListNavMixin, ModalScreen[Optional[List[str]]]): with Vertical(id='trailer-dialog'): yield Label('Select trailers:') yield ListView( - *[TrailerOption(name, name in self._existing) for name in self.TRAILER_NAMES], + *[ + TrailerOption(name, name in self._existing) + for name in self.TRAILER_NAMES + ], id='trailer-list', ) yield Static('a/r/t/n toggle | Enter save', id='trailer-hint') @@ -145,7 +148,9 @@ class TrailerScreen(JKListNavMixin, ModalScreen[Optional[List[str]]]): def action_toggle_item(self) -> None: lv = self.query_one('#trailer-list', ListView) - if lv.highlighted_child is not None and isinstance(lv.highlighted_child, TrailerOption): + if lv.highlighted_child is not None and isinstance( + lv.highlighted_child, TrailerOption + ): lv.highlighted_child.toggle() def action_quick_toggle(self, name: str) -> None: @@ -401,8 +406,13 @@ class NoteScreen(ModalScreen[Optional[str]]): def compose(self) -> ComposeResult: with Vertical(id='note-dialog'): - yield RichLog(id='note-viewer', highlight=False, wrap=True, - markup=True, auto_scroll=False) + yield RichLog( + id='note-viewer', + highlight=False, + wrap=True, + markup=True, + auto_scroll=False, + ) yield Static('Escape close | e edit | d delete all', id='note-hint') def on_mount(self) -> None: @@ -471,8 +481,13 @@ class PriorReviewScreen(ModalScreen[None]): def compose(self) -> ComposeResult: with Vertical(id='prior-review-dialog'): - yield RichLog(id='prior-review-viewer', highlight=False, wrap=True, - markup=False, auto_scroll=False) + yield RichLog( + id='prior-review-viewer', + highlight=False, + wrap=True, + markup=False, + auto_scroll=False, + ) yield Static('Escape close', id='prior-review-hint') def on_mount(self) -> None: @@ -480,7 +495,7 @@ class PriorReviewScreen(ModalScreen[None]): viewer = self.query_one('#prior-review-viewer', RichLog) for line in self._context_text.splitlines(): if line.startswith('== ') and line.endswith(' =='): - viewer.write(Text(line, style=f"bold {ts['accent']}")) + viewer.write(Text(line, style=f'bold {ts["accent"]}')) else: viewer.write(Text(line)) @@ -537,10 +552,16 @@ class FollowupReplyPreviewScreen(ModalScreen[Optional[str]]): def compose(self) -> ComposeResult: with Vertical(id='followup-preview-dialog'): - yield RichLog(id='followup-preview-viewer', highlight=False, - wrap=True, markup=False, auto_scroll=False) - yield Static('S send | e edit | Escape abandon', - id='followup-preview-hint') + yield RichLog( + id='followup-preview-viewer', + highlight=False, + wrap=True, + markup=False, + auto_scroll=False, + ) + yield Static( + 'S send | e edit | Escape abandon', id='followup-preview-hint' + ) def on_mount(self) -> None: body = self._reply_text @@ -691,11 +712,15 @@ class TakeScreen(ModalScreen[bool]): } """ - def __init__(self, target_branch: str, review_branch: str, - num_patches: int = 0, - default_method: Optional[str] = None, - recent_branches: Optional[List[str]] = None, - subject: str = '') -> None: + def __init__( + self, + target_branch: str, + review_branch: str, + num_patches: int = 0, + default_method: Optional[str] = None, + recent_branches: Optional[List[str]] = None, + subject: str = '', + ) -> None: """Initialize take screen. Args: @@ -709,7 +734,9 @@ class TakeScreen(ModalScreen[bool]): super().__init__() self._target_branch = target_branch self._review_branch = review_branch - self._default_method = default_method or ('linear' if num_patches == 1 else 'merge') + self._default_method = default_method or ( + 'linear' if num_patches == 1 else 'merge' + ) self._recent_branches = recent_branches self._subject = subject # Results set after continue @@ -731,12 +758,30 @@ class TakeScreen(ModalScreen[bool]): yield Static(self._subject, id='take-title', markup=False) yield Static(f'Review branch: {self._review_branch}', classes='take-value') yield Static('Target branch:', classes='take-label') - suggester = SuggestFromList(self._recent_branches, case_sensitive=True) if self._recent_branches else None - yield Input(value=self._target_branch, id='take-target', suggester=suggester) + suggester = ( + SuggestFromList(self._recent_branches, case_sensitive=True) + if self._recent_branches + else None + ) + yield Input( + value=self._target_branch, id='take-target', suggester=suggester + ) yield Static('Method:', classes='take-label') - yield Select(method_options, value=self._default_method, id='take-method', allow_blank=False) - yield Checkbox('add Link:', value=True, id='take-add-link', classes='take-checkbox') - yield Checkbox('add Signed-off-by:', value=True, id='take-add-signoff', classes='take-checkbox') + yield Select( + method_options, + value=self._default_method, + id='take-method', + allow_blank=False, + ) + yield Checkbox( + 'add Link:', value=True, id='take-add-link', classes='take-checkbox' + ) + yield Checkbox( + 'add Signed-off-by:', + value=True, + id='take-add-signoff', + classes='take-checkbox', + ) yield Static('Ctrl-y continue | Escape cancel', id='take-hint') def on_mount(self) -> None: @@ -748,7 +793,9 @@ class TakeScreen(ModalScreen[bool]): self.notify('Target branch is required', severity='error') return if not b4.git_branch_exists(None, self.target_result): - self.notify(f'Branch does not exist: {self.target_result}', severity='error') + self.notify( + f'Branch does not exist: {self.target_result}', severity='error' + ) return self.method_result = str(self.query_one('#take-method', Select).value) self.add_link = self.query_one('#take-add-link', Checkbox).value @@ -801,8 +848,9 @@ class CherryPickScreen(ModalScreen[bool]): } """ - def __init__(self, patches: List[Dict[str, Any]], - preselected: Optional[List[int]] = None) -> None: + def __init__( + self, patches: List[Dict[str, Any]], preselected: Optional[List[int]] = None + ) -> None: super().__init__() self._patches = patches self._preselected: List[int] = preselected if preselected is not None else [] @@ -813,14 +861,19 @@ class CherryPickScreen(ModalScreen[bool]): with Vertical(id='cherrypick-dialog'): yield Static('Select patches to apply', id='cherrypick-title') if has_preselected: - yield Static('Skipped patches are pre-deselected.', - id='cherrypick-skip-note') + yield Static( + 'Skipped patches are pre-deselected.', id='cherrypick-skip-note' + ) with Vertical(id='cherrypick-list'): for i, patch in enumerate(self._patches): title = patch.get('title', f'Patch {i + 1}') checked = (i + 1) in self._preselected if has_preselected else False - yield Checkbox(Text(f' {i + 1:3d}. {title}'), value=checked, - id=f'cherrypick-{i}', classes='cherrypick-checkbox') + yield Checkbox( + Text(f' {i + 1:3d}. {title}'), + value=checked, + id=f'cherrypick-{i}', + classes='cherrypick-checkbox', + ) yield Static('Ctrl-y continue | Escape cancel', id='cherrypick-hint') def action_continue_pick(self) -> None: @@ -887,9 +940,14 @@ class TakeConfirmScreen(ModalScreen[bool]): } """ - def __init__(self, method: str, target_branch: str, - review_branch: str, subject: str = '', - cherrypick: Optional[List[int]] = None) -> None: + def __init__( + self, + method: str, + target_branch: str, + review_branch: str, + subject: str = '', + cherrypick: Optional[List[int]] = None, + ) -> None: super().__init__() self._method = method self._target_branch = target_branch @@ -908,14 +966,12 @@ class TakeConfirmScreen(ModalScreen[bool]): if self._cherrypick: yield Static( f'Patches: {", ".join(str(i) for i in self._cherrypick)}', - markup=False) + markup=False, + ) yield Static('Testing apply\u2026', id='takeconfirm-status') yield LoadingIndicator(id='takeconfirm-loading') - yield Checkbox('mark as accepted', value=True, - id='takeconfirm-accept') - yield Static( - 'Ctrl-y confirm | Escape cancel', - id='takeconfirm-hint') + yield Checkbox('mark as accepted', value=True, id='takeconfirm-accept') + yield Static('Ctrl-y confirm | Escape cancel', id='takeconfirm-hint') def on_mount(self) -> None: self.run_worker(self._test_take, name='_test_take', thread=True) @@ -931,8 +987,7 @@ class TakeConfirmScreen(ModalScreen[bool]): # Load tracking to find base-commit and patch count try: - _cover, tracking = b4.review.load_tracking( - topdir, self._review_branch) + _cover, tracking = b4.review.load_tracking(topdir, self._review_branch) except SystemExit: return False, 'could not load tracking data' @@ -957,15 +1012,16 @@ class TakeConfirmScreen(ModalScreen[bool]): # Resolve the test base ecode, out = b4.git_run_command( - topdir, ['rev-parse', '--verify', test_base]) + topdir, ['rev-parse', '--verify', test_base] + ) if ecode != 0: return False, f'cannot resolve base: {test_base}' resolved_base = out.strip() # Get patch commits commits = b4.git_get_command_lines( - topdir, ['rev-list', '--reverse', - f'{patch_base}..{patch_tip}']) + topdir, ['rev-list', '--reverse', f'{patch_base}..{patch_tip}'] + ) if not commits: return False, 'no commits found on review branch' @@ -983,9 +1039,8 @@ class TakeConfirmScreen(ModalScreen[bool]): mbox_parts: list[bytes] = [] for commit in commits: ecode, patch_bytes = b4.git_run_command( - topdir, - ['format-patch', '--stdout', '-1', commit], - decode=False) + topdir, ['format-patch', '--stdout', '-1', commit], decode=False + ) if ecode != 0: return False, f'format-patch failed for {commit[:12]}' mbox_parts.append(patch_bytes) @@ -994,16 +1049,13 @@ class TakeConfirmScreen(ModalScreen[bool]): # Test apply in a temporary sparse worktree try: with b4.git_temp_worktree(topdir, resolved_base) as gwt: - ecode, out = b4.git_run_command( - gwt, ['sparse-checkout', 'set']) + ecode, out = b4.git_run_command(gwt, ['sparse-checkout', 'set']) if ecode > 0: return False, 'failed to set up worktree' - ecode, out = b4.git_run_command( - gwt, ['checkout', '-f']) + ecode, out = b4.git_run_command(gwt, ['checkout', '-f']) if ecode > 0: return False, 'failed to checkout base' - ecode, out = b4.git_run_command( - gwt, ['am'], stdin=ambytes) + ecode, out = b4.git_run_command(gwt, ['am'], stdin=ambytes) if ecode > 0: for line in out.splitlines(): if line.startswith('Patch failed at '): @@ -1016,29 +1068,25 @@ class TakeConfirmScreen(ModalScreen[bool]): def _update_status(self, text: str, level: str) -> None: widget = self.query_one('#takeconfirm-status', Static) widget.update(text) - widget.remove_class('takeconfirm-pass', 'takeconfirm-warn', - 'takeconfirm-fail') + widget.remove_class('takeconfirm-pass', 'takeconfirm-warn', 'takeconfirm-fail') widget.add_class(f'takeconfirm-{level}') async def on_worker_state_changed(self, event: Worker.StateChanged) -> None: if event.worker.name != '_test_take': return if event.state == WorkerState.SUCCESS and event.worker.result: - await self.query_one('#takeconfirm-loading', LoadingIndicator - ).remove() + await self.query_one('#takeconfirm-loading', LoadingIndicator).remove() ok, detail = event.worker.result if ok: self._update_status(f'Test apply: {detail}', 'pass') else: self._update_status(f'Test apply: {detail}', 'fail') elif event.state == WorkerState.ERROR: - await self.query_one('#takeconfirm-loading', LoadingIndicator - ).remove() + await self.query_one('#takeconfirm-loading', LoadingIndicator).remove() self._update_status('test apply error', 'fail') def action_confirm_take(self) -> None: - self.accept_series = self.query_one( - '#takeconfirm-accept', Checkbox).value + self.accept_series = self.query_one('#takeconfirm-accept', Checkbox).value self.dismiss(True) def action_cancel(self) -> None: @@ -1087,8 +1135,9 @@ class SnoozeScreen(ModalScreen[Optional[Dict[str, str]]]): } """ - def __init__(self, last_source: str = '', last_input: str = '', - subject: str = '') -> None: + def __init__( + self, last_source: str = '', last_input: str = '', subject: str = '' + ) -> None: super().__init__() self._last_source = last_source self._last_input = last_input @@ -1181,7 +1230,9 @@ class SnoozeScreen(ModalScreen[Optional[Dict[str, str]]]): return # Convert date to midnight UTC datetime target = datetime.datetime( - target_date.year, target_date.month, target_date.day, + target_date.year, + target_date.month, + target_date.day, tzinfo=datetime.timezone.utc, ) until_value = target.strftime('%Y-%m-%dT%H:%M:%S') @@ -1235,16 +1286,22 @@ class ThankScreen(ModalScreen[Optional[str]]): } """ - def __init__(self, msg: email.message.EmailMessage, - checkurl: Optional[str] = None) -> None: + def __init__( + self, msg: email.message.EmailMessage, checkurl: Optional[str] = None + ) -> None: super().__init__() self._msg = msg self._checkurl = checkurl def compose(self) -> ComposeResult: with Vertical(id='thank-dialog'): - yield RichLog(id='thank-viewer', highlight=False, wrap=True, - markup=False, auto_scroll=False) + yield RichLog( + id='thank-viewer', + highlight=False, + wrap=True, + markup=False, + auto_scroll=False, + ) if self._checkurl: hint = 'e edit | S send now | W queue | Escape cancel' else: @@ -1342,8 +1399,13 @@ class QueueScreen(ModalScreen[Optional[str]]): def compose(self) -> ComposeResult: with Vertical(id='queue-dialog'): - yield RichLog(id='queue-viewer', highlight=False, wrap=True, - markup=False, auto_scroll=False) + yield RichLog( + id='queue-viewer', + highlight=False, + wrap=True, + markup=False, + auto_scroll=False, + ) yield Static('Q deliver | Escape close', id='queue-hint') def on_mount(self) -> None: @@ -1365,7 +1427,9 @@ class QueueScreen(ModalScreen[Optional[str]]): self.dismiss(None) -class QueueDeliveryScreen(ModalScreen[Optional[Tuple[int, int, List[Tuple[str, int]]]]]): +class QueueDeliveryScreen( + ModalScreen[Optional[Tuple[int, int, List[Tuple[str, int]]]]] +): """Modal that processes the thanks queue with a progress bar. Dismisses with ``(delivered, still_pending, delivered_series)`` @@ -1390,9 +1454,9 @@ class QueueDeliveryScreen(ModalScreen[Optional[Tuple[int, int, List[Tuple[str, i } """ - def __init__(self, total: int, - dryrun: bool = False, - patatt_sign: bool = True) -> None: + def __init__( + self, total: int, dryrun: bool = False, patatt_sign: bool = True + ) -> None: super().__init__() self._total = total self._dryrun = dryrun @@ -1417,7 +1481,9 @@ class QueueDeliveryScreen(ModalScreen[Optional[Tuple[int, int, List[Tuple[str, i def _on_progress(completed: int, total: int, status: str) -> None: if not self._cancelled: - self.app.call_from_thread(self._update_progress, completed, total, status) + self.app.call_from_thread( + self._update_progress, completed, total, status + ) return b4.ty.process_queue( dryrun=self._dryrun, @@ -1553,8 +1619,13 @@ class _FetchViewerScreen(ModalScreen[None]): with Vertical(id='fv-dialog'): yield Static(self._loading_text, id='fv-title', markup=False) yield LoadingIndicator(id='fv-loading') - yield RichLog(id='fv-viewer', highlight=False, wrap=True, - markup=True, auto_scroll=False) + yield RichLog( + id='fv-viewer', + highlight=False, + wrap=True, + markup=True, + auto_scroll=False, + ) yield Static('Escape close', id='fv-hint') def on_mount(self) -> None: @@ -1623,7 +1694,9 @@ class ViewSeriesScreen(_FetchViewerScreen): if not first: viewer.write(Rule()) first = False - viewer.write(Text(f'From: {lmsg.fromname} <{lmsg.fromemail}>', style='bold')) + viewer.write( + Text(f'From: {lmsg.fromname} <{lmsg.fromemail}>', style='bold') + ) if lmsg.date: viewer.write(Text(f'Date: {lmsg.date}', style='bold')) viewer.write(Text(f'Subject: {lmsg.full_subject}', style='bold')) @@ -1644,8 +1717,7 @@ class CIChecksScreen(_FetchViewerScreen): _loading_text = 'Fetching CI checks\u2026' - def __init__(self, pwkey: str, pwurl: str, - series: Dict[str, Any]) -> None: + def __init__(self, pwkey: str, pwurl: str, series: Dict[str, Any]) -> None: super().__init__() self._pwkey = pwkey self._pwurl = pwurl @@ -1653,15 +1725,14 @@ class CIChecksScreen(_FetchViewerScreen): def _fetch(self) -> List[Dict[str, Any]]: import b4.review + with _quiet_worker(): patch_ids = self._series.get('patch_ids', []) - return b4.review.pw_fetch_checks( - self._pwkey, self._pwurl, patch_ids) + return b4.review.pw_fetch_checks(self._pwkey, self._pwurl, patch_ids) def _show_result(self, checks: List[Dict[str, Any]]) -> None: series_name = self._series.get('name') or '(no subject)' - self.query_one('#fv-title', Static).update( - f'CI checks \u2014 {series_name}') + self.query_one('#fv-title', Static).update(f'CI checks \u2014 {series_name}') viewer = self.query_one('#fv-viewer', RichLog) if not checks: @@ -1715,7 +1786,9 @@ class CIChecksScreen(_FetchViewerScreen): viewer.write(Text(f' \u2192 {target_url}', style='dim')) -def NewerRevisionWarningScreen(current_rev: int, newer_versions: List[int]) -> ConfirmScreen: +def NewerRevisionWarningScreen( + current_rev: int, newer_versions: List[int] +) -> ConfirmScreen: """Build a confirmation screen warning about newer revisions.""" versions = ', '.join(f'v{v}' for v in newer_versions) return ConfirmScreen( @@ -1773,14 +1846,16 @@ class RevisionChoiceScreen(ModalScreen[Optional[int]]): yield Static('Newer revision available', id='rev-choice-title') yield Static( f'This series was tracked as v{self._current_rev}, but ' - f'v{self._newest_rev} is now available.') + f'v{self._newest_rev} is now available.' + ) yield Static('') yield Static('Which version would you like to review?') yield Static( f'n review v{self._newest_rev} (newer) | ' f'o review v{self._current_rev} (older) | ' f'Escape cancel', - id='rev-choice-hint') + id='rev-choice-hint', + ) def action_newer(self) -> None: self.dismiss(self._newest_rev) @@ -1848,9 +1923,13 @@ class RebaseScreen(ModalScreen[bool]): } """ - def __init__(self, current_branch: str, review_branch: str, - recent_branches: Optional[List[str]] = None, - subject: str = '') -> None: + def __init__( + self, + current_branch: str, + review_branch: str, + recent_branches: Optional[List[str]] = None, + subject: str = '', + ) -> None: super().__init__() self._current_branch = current_branch self._review_branch = review_branch @@ -1865,14 +1944,22 @@ class RebaseScreen(ModalScreen[bool]): dialog.border_title = 'Rebase Series' if self._subject: yield Static(self._subject, id='rebase-title', markup=False) - yield Static(f'Review branch: {self._review_branch}', classes='rebase-value') + yield Static( + f'Review branch: {self._review_branch}', classes='rebase-value' + ) yield Static('Rebase on top of:', classes='rebase-label') - suggester = SuggestFromList(self._recent_branches, case_sensitive=True) if self._recent_branches else None - yield Input(value=self._current_branch, id='rebase-target', suggester=suggester) + suggester = ( + SuggestFromList(self._recent_branches, case_sensitive=True) + if self._recent_branches + else None + ) + yield Input( + value=self._current_branch, id='rebase-target', suggester=suggester + ) yield Static('', id='rebase-status', markup=False) yield Static( - 'Enter check | Ctrl-y confirm | Escape cancel', - id='rebase-hint') + 'Enter check | Ctrl-y confirm | Escape cancel', id='rebase-hint' + ) def on_mount(self) -> None: self.query_one('#rebase-target', Input).focus() @@ -1898,18 +1985,17 @@ class RebaseScreen(ModalScreen[bool]): self._run_test_apply(value) else: self._update_status('Testing applicability\u2026', 'warn') - self.run_worker( - self._prepare_local, name='_prepare', thread=True) + self.run_worker(self._prepare_local, name='_prepare', thread=True) def _prepare_local(self) -> bytes: """Build mbox from the local review branch patches.""" import b4.review + topdir = b4.git_get_toplevel() if not topdir: raise RuntimeError('Not in a git repository') with _quiet_worker(): - _cover, tracking = b4.review.load_tracking( - topdir, self._review_branch) + _cover, tracking = b4.review.load_tracking(topdir, self._review_branch) series_data = tracking.get('series', {}) base_commit = series_data.get('base-commit', '') first_patch = series_data.get('first-patch-commit', '') @@ -1919,9 +2005,10 @@ class RebaseScreen(ModalScreen[bool]): range_start = base_commit range_end = f'{self._review_branch}~1' ecode, ambytes = b4.git_run_command( - topdir, ['format-patch', '--stdout', - f'{range_start}..{range_end}'], - decode=False) + topdir, + ['format-patch', '--stdout', f'{range_start}..{range_end}'], + decode=False, + ) if ecode > 0: raise RuntimeError('Could not generate patches from review branch') return ambytes @@ -1932,28 +2019,25 @@ class RebaseScreen(ModalScreen[bool]): assert ambytes is not None self.run_worker( lambda: self._test_apply_at(ambytes, branch), - name='_test_apply', thread=True, + name='_test_apply', + thread=True, ) @staticmethod - def _test_apply_at(ambytes: bytes, - branch: str) -> Tuple[bool, str]: + def _test_apply_at(ambytes: bytes, branch: str) -> Tuple[bool, str]: topdir = b4.git_get_toplevel() if not topdir: return False, 'not in a git repository' with _quiet_worker(): try: with b4.git_temp_worktree(topdir, branch) as gwt: - ecode, out = b4.git_run_command( - gwt, ['sparse-checkout', 'set']) + ecode, out = b4.git_run_command(gwt, ['sparse-checkout', 'set']) if ecode > 0: return False, 'failed to set up worktree' - ecode, out = b4.git_run_command( - gwt, ['checkout', '-f']) + ecode, out = b4.git_run_command(gwt, ['checkout', '-f']) if ecode > 0: return False, 'failed to checkout base' - ecode, out = b4.git_run_command( - gwt, ['am'], stdin=ambytes) + ecode, out = b4.git_run_command(gwt, ['am'], stdin=ambytes) if ecode > 0: for line in out.splitlines(): if line.startswith('Patch failed at '): @@ -1987,7 +2071,9 @@ class RebaseScreen(ModalScreen[bool]): self.notify('Target branch is required', severity='error') return if not b4.git_branch_exists(None, self.target_result): - self.notify(f'Branch does not exist: {self.target_result}', severity='error') + self.notify( + f'Branch does not exist: {self.target_result}', severity='error' + ) return self.dismiss(True) @@ -2050,12 +2136,15 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): } """ - def __init__(self, current_target: str = '', - suggestions: Optional[List[str]] = None, - subject: str = '', - message_id: str = '', - revision: Optional[int] = None, - review_branch: Optional[str] = None) -> None: + def __init__( + self, + current_target: str = '', + suggestions: Optional[List[str]] = None, + subject: str = '', + message_id: str = '', + revision: Optional[int] = None, + review_branch: Optional[str] = None, + ) -> None: super().__init__() self._current_target = current_target self._suggestions = suggestions @@ -2072,9 +2161,19 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): dialog.border_title = 'Set Target Branch' if self._subject: yield Static(self._subject, id='target-branch-title', markup=False) - yield Static('Target branch for this series:', classes='target-branch-label') - suggester = SuggestFromList(self._suggestions, case_sensitive=True) if self._suggestions else None - yield Input(value=self._current_target, id='target-branch-input', suggester=suggester) + yield Static( + 'Target branch for this series:', classes='target-branch-label' + ) + suggester = ( + SuggestFromList(self._suggestions, case_sensitive=True) + if self._suggestions + else None + ) + yield Input( + value=self._current_target, + id='target-branch-input', + suggester=suggester, + ) yield Static('', id='target-branch-status', markup=False) yield Static( 'Enter check | Ctrl-y confirm | Ctrl-d clear | Escape cancel', @@ -2106,12 +2205,10 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): self._check_applicability(value) elif self._review_branch: self._update_status('Testing applicability\u2026', 'warn') - self.run_worker( - self._prepare_local, name='_prepare', thread=True) + self.run_worker(self._prepare_local, name='_prepare', thread=True) elif self._message_id: self._update_status('Fetching series\u2026', 'warn') - self.run_worker( - self._prepare_remote, name='_prepare', thread=True) + self.run_worker(self._prepare_remote, name='_prepare', thread=True) else: self._update_status(f'Branch exists: {value}', 'pass') @@ -2122,8 +2219,7 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): if not topdir: raise RuntimeError('Not in a git repository') with _quiet_worker(): - _cover, tracking = b4.review.load_tracking( - topdir, self._review_branch) + _cover, tracking = b4.review.load_tracking(topdir, self._review_branch) series_data = tracking.get('series', {}) base_commit = series_data.get('base-commit', '') first_patch = series_data.get('first-patch-commit', '') @@ -2133,9 +2229,10 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): range_start = base_commit range_end = f'{self._review_branch}~1' ecode, ambytes = b4.git_run_command( - topdir, ['format-patch', '--stdout', - f'{range_start}..{range_end}'], - decode=False) + topdir, + ['format-patch', '--stdout', f'{range_start}..{range_end}'], + decode=False, + ) if ecode > 0: raise RuntimeError('Could not generate patches from review branch') return None, ambytes @@ -2144,12 +2241,16 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): """Fetch series from lore and build LoreSeries + ambytes.""" with _quiet_worker(): msgs = b4.review._retrieve_messages(self._message_id) - lser = b4.review._get_lore_series( - msgs, wantver=self._revision) + lser = b4.review._get_lore_series(msgs, wantver=self._revision) am_msgs = lser.get_am_ready( - noaddtrailers=True, addmysob=False, addlink=False, - cherrypick=None, copyccs=False, allowbadchars=False, - showchecks=False) + noaddtrailers=True, + addmysob=False, + addlink=False, + cherrypick=None, + copyccs=False, + allowbadchars=False, + showchecks=False, + ) if not am_msgs: raise LookupError('No patches ready for applying') ifh = io.BytesIO() @@ -2168,16 +2269,16 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): # Fast blob check if we have a LoreSeries with indexes (remote fetch) if self._lser and self._lser.indexes: try: - checked, mismatches = self._lser.check_applies_clean( - topdir, at=branch) + checked, mismatches = self._lser.check_applies_clean(topdir, at=branch) if len(mismatches) == 0: - self._update_status( - f'Apply results: clean ({branch})', 'pass') + self._update_status(f'Apply results: clean ({branch})', 'pass') return matched = checked - len(mismatches) self._update_status( f'Apply results: {matched}/{checked} a/b blobs match' - f' \u2014 testing\u2026', 'warn') + f' \u2014 testing\u2026', + 'warn', + ) except Exception: self._update_status('Testing applicability\u2026', 'warn') else: @@ -2190,28 +2291,25 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): ambytes = self._ambytes self.run_worker( lambda: self._test_apply_at(ambytes, branch), - name='_test_apply', thread=True, + name='_test_apply', + thread=True, ) @staticmethod - def _test_apply_at(ambytes: bytes, - branch: str) -> Tuple[bool, str]: + def _test_apply_at(ambytes: bytes, branch: str) -> Tuple[bool, str]: topdir = b4.git_get_toplevel() if not topdir: return False, 'not in a git repository' with _quiet_worker(): try: with b4.git_temp_worktree(topdir, branch) as gwt: - ecode, out = b4.git_run_command( - gwt, ['sparse-checkout', 'set']) + ecode, out = b4.git_run_command(gwt, ['sparse-checkout', 'set']) if ecode > 0: return False, 'failed to set up worktree' - ecode, out = b4.git_run_command( - gwt, ['checkout', '-f']) + ecode, out = b4.git_run_command(gwt, ['checkout', '-f']) if ecode > 0: return False, 'failed to checkout base' - ecode, out = b4.git_run_command( - gwt, ['am'], stdin=ambytes) + ecode, out = b4.git_run_command(gwt, ['am'], stdin=ambytes) if ecode > 0: for line in out.splitlines(): if line.startswith('Patch failed at '): @@ -2242,7 +2340,9 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): def action_confirm(self) -> None: value = self.query_one('#target-branch-input', Input).value.strip() if not value: - self.notify('Branch name is required (use Ctrl-d to clear)', severity='error') + self.notify( + 'Branch name is required (use Ctrl-d to clear)', severity='error' + ) return if not b4.git_branch_exists(None, value): self.notify(f'Branch does not exist: {value}', severity='error') @@ -2256,9 +2356,9 @@ class TargetBranchScreen(ModalScreen[Optional[str]]): self.dismiss(None) -def AbandonConfirmScreen(change_id: str, review_branch: str, - has_branch: bool, - subject: str = '') -> ConfirmScreen: +def AbandonConfirmScreen( + change_id: str, review_branch: str, has_branch: bool, subject: str = '' +) -> ConfirmScreen: """Build a confirmation screen for abandon operation.""" body = [f'Change-ID: {change_id}'] if has_branch: @@ -2277,9 +2377,9 @@ def AbandonConfirmScreen(change_id: str, review_branch: str, ) -def ArchiveConfirmScreen(change_id: str, review_branch: str, - has_branch: bool, - subject: str = '') -> ConfirmScreen: +def ArchiveConfirmScreen( + change_id: str, review_branch: str, has_branch: bool, subject: str = '' +) -> ConfirmScreen: """Build a confirmation screen for archive operation.""" body = [f'Change-ID: {change_id}'] if has_branch: @@ -2337,11 +2437,15 @@ class RangeDiffScreen(JKListNavMixin, ModalScreen[Optional[int]]): self._current_revision = current_revision self._revisions = sorted( [r for r in revisions if r['revision'] != current_revision], - key=lambda r: r['revision'], reverse=True) + key=lambda r: r['revision'], + reverse=True, + ) def compose(self) -> ComposeResult: with Vertical(id='rangediff-dialog'): - yield Label(f'Range-diff against v{self._current_revision} \u2014 select version:') + yield Label( + f'Range-diff against v{self._current_revision} \u2014 select version:' + ) items = [] for r in self._revisions: subject = r.get('subject', '(no subject)') @@ -2431,8 +2535,10 @@ class SetStateScreen(JKListNavMixin, ModalScreen[Optional[Tuple[str, bool]]]): with Vertical(id='state-dialog'): yield Label('Set state (Enter=confirm, Esc=cancel):') yield ListView( - *[StateOption(s['slug'], s['name'], s['slug'] == self._current_state) - for s in self._states], + *[ + StateOption(s['slug'], s['name'], s['slug'] == self._current_state) + for s in self._states + ], id='state-list', ) yield Checkbox('Archived', False, id='state-archived') @@ -2459,7 +2565,9 @@ class SetStateScreen(JKListNavMixin, ModalScreen[Optional[Tuple[str, bool]]]): def _do_confirm(self) -> None: lv = self.query_one('#state-list', ListView) - if lv.highlighted_child is not None and isinstance(lv.highlighted_child, StateOption): + if lv.highlighted_child is not None and isinstance( + lv.highlighted_child, StateOption + ): slug = lv.highlighted_child.slug else: self.dismiss(None) @@ -2501,8 +2609,15 @@ class ApplyStateModal(ModalScreen[Tuple[int, int, str]]): } """ - def __init__(self, pwkey: str, pwurl: str, patch_ids: List[int], - new_state: str, archived: bool, series_name: str) -> None: + def __init__( + self, + pwkey: str, + pwurl: str, + patch_ids: List[int], + new_state: str, + archived: bool, + series_name: str, + ) -> None: super().__init__() self._pwkey = pwkey self._pwurl = pwurl @@ -2517,8 +2632,12 @@ class ApplyStateModal(ModalScreen[Tuple[int, int, str]]): with Vertical(id='apply-dialog'): yield Label(f'Setting state to: {self._new_state}', id='apply-title') yield Label(self._series_name, id='apply-series', markup=False) - yield Label(f'Processing 0/{len(self._patch_ids)} patches...', id='apply-status') - yield ProgressBar(total=len(self._patch_ids), show_eta=False, id='apply-progress') + yield Label( + f'Processing 0/{len(self._patch_ids)} patches...', id='apply-status' + ) + yield ProgressBar( + total=len(self._patch_ids), show_eta=False, id='apply-progress' + ) def on_mount(self) -> None: self.run_worker(self._apply_states, name='_apply_states', thread=True) @@ -2600,9 +2719,13 @@ class UpdateAllScreen(ModalScreen[Dict[str, Any]]): Binding('q', 'cancel', 'Cancel', show=False), ] - def __init__(self, series_list: List[Dict[str, Any]], - identifier: str, linkmask: str, - topdir: Optional[str] = None) -> None: + def __init__( + self, + series_list: List[Dict[str, Any]], + identifier: str, + linkmask: str, + topdir: Optional[str] = None, + ) -> None: super().__init__() self._series_list = series_list self._identifier = identifier @@ -2624,9 +2747,13 @@ class UpdateAllScreen(ModalScreen[Dict[str, Any]]): count = len(self._series_list) title = 'Updating series' if count == 1 else 'Updating all tracked series' yield Label(title, id='updateall-title') - yield Label(f'Checking 0/{len(self._series_list)} series...', id='updateall-status') + yield Label( + f'Checking 0/{len(self._series_list)} series...', id='updateall-status' + ) yield Label('', id='updateall-series', markup=False) - yield ProgressBar(total=len(self._series_list), show_eta=False, id='updateall-progress') + yield ProgressBar( + total=len(self._series_list), show_eta=False, id='updateall-progress' + ) def on_mount(self) -> None: self.run_worker(self._do_updates, name='_do_updates', thread=True) @@ -2643,7 +2770,8 @@ class UpdateAllScreen(ModalScreen[Dict[str, Any]]): if self._topdir: try: rescan = b4.review.tracking.rescan_branches( - self._identifier, self._topdir) + self._identifier, self._topdir + ) self._result['gone'] = rescan.get('gone', 0) except Exception as ex: logger.warning('Pre-update rescan failed: %s', ex) @@ -2656,7 +2784,9 @@ class UpdateAllScreen(ModalScreen[Dict[str, Any]]): self.app.call_from_thread(self._update_progress, i, subject) r = b4.review.update_series_tracking( - series, self._identifier, self._linkmask, + series, + self._identifier, + self._linkmask, topdir=self._topdir, ) self._result['series_checked'] += 1 @@ -2748,12 +2878,15 @@ class BaseSelectionScreen(ModalScreen[Optional[str]]): } """ - def __init__(self, initial_base: str, - lser: 'b4.LoreSeries', - ambytes: bytes, - base_suggestions: Optional[List[str]] = None, - base_hint: str = '', - subject: str = '') -> None: + def __init__( + self, + initial_base: str, + lser: 'b4.LoreSeries', + ambytes: bytes, + base_suggestions: Optional[List[str]] = None, + base_hint: str = '', + subject: str = '', + ) -> None: """Initialize the base selection screen. Args: @@ -2779,18 +2912,24 @@ class BaseSelectionScreen(ModalScreen[Optional[str]]): if self._subject: yield Static(self._subject, id='base-title', markup=False) if self._base_hint: - yield Static(self._base_hint, id='base-hint', - classes='base-warn', markup=False) + yield Static( + self._base_hint, id='base-hint', classes='base-warn', markup=False + ) yield Static('Base:', markup=False) - suggester = SuggestFromList( - self._base_suggestions, case_sensitive=True, - ) if self._base_suggestions else None - yield Input(value=self._initial_base, id='base-input', - suggester=suggester) + suggester = ( + SuggestFromList( + self._base_suggestions, + case_sensitive=True, + ) + if self._base_suggestions + else None + ) + yield Input(value=self._initial_base, id='base-input', suggester=suggester) yield Static('', id='base-status', markup=False) yield Static( 'Enter check | Ctrl-y confirm | Escape cancel', - id='base-footer', markup=False, + id='base-footer', + markup=False, ) def on_mount(self) -> None: @@ -2812,8 +2951,7 @@ class BaseSelectionScreen(ModalScreen[Optional[str]]): self._update_status('not in a git repository', 'fail') return - ecode, out = b4.git_run_command( - topdir, ['rev-parse', '--verify', value]) + ecode, out = b4.git_run_command(topdir, ['rev-parse', '--verify', value]) if ecode != 0: self._update_status(f'not a valid ref: {value}', 'fail') self._resolved_base = None @@ -2824,22 +2962,24 @@ class BaseSelectionScreen(ModalScreen[Optional[str]]): if self._lser.indexes: try: checked, mismatches = self._lser.check_applies_clean( - topdir, at=self._resolved_base) + topdir, at=self._resolved_base + ) if len(mismatches) == 0: self._update_status( - f'Apply results: clean ({self._resolved_base[:12]})', - 'pass') + f'Apply results: clean ({self._resolved_base[:12]})', 'pass' + ) else: matched = checked - len(mismatches) self._update_status( f'Apply results: {matched}/{checked} a/b blobs match' - f' — testing\u2026', 'warn') + f' — testing\u2026', + 'warn', + ) self._run_test_apply() except Exception: self._update_status('could not check applicability', 'warn') else: - self._update_status( - f'will use {self._resolved_base[:12]}', 'pass') + self._update_status(f'will use {self._resolved_base[:12]}', 'pass') def on_input_submitted(self, event: Input.Submitted) -> None: """Validate the entered base ref.""" @@ -2858,12 +2998,12 @@ class BaseSelectionScreen(ModalScreen[Optional[str]]): return self.run_worker( lambda: self._test_apply_at(self._ambytes, base), - name='_test_apply', thread=True, + name='_test_apply', + thread=True, ) @staticmethod - def _test_apply_at(ambytes: bytes, - base: str) -> Tuple[bool, str]: + def _test_apply_at(ambytes: bytes, base: str) -> Tuple[bool, str]: """Run git-am in a throwaway sparse worktree. Returns (ok, detail).""" topdir = b4.git_get_toplevel() if not topdir: @@ -2871,16 +3011,13 @@ class BaseSelectionScreen(ModalScreen[Optional[str]]): with _quiet_worker(): try: with b4.git_temp_worktree(topdir, base) as gwt: - ecode, out = b4.git_run_command( - gwt, ['sparse-checkout', 'set']) + ecode, out = b4.git_run_command(gwt, ['sparse-checkout', 'set']) if ecode > 0: return False, 'failed to set up worktree' - ecode, out = b4.git_run_command( - gwt, ['checkout', '-f']) + ecode, out = b4.git_run_command(gwt, ['checkout', '-f']) if ecode > 0: return False, 'failed to checkout base' - ecode, out = b4.git_run_command( - gwt, ['am'], stdin=ambytes) + ecode, out = b4.git_run_command(gwt, ['am'], stdin=ambytes) if ecode > 0: # Extract just the "Patch failed" line for line in out.splitlines(): @@ -2915,8 +3052,7 @@ class BaseSelectionScreen(ModalScreen[Optional[str]]): topdir = b4.git_get_toplevel() if not topdir: return - ecode, out = b4.git_run_command( - topdir, ['rev-parse', '--verify', value]) + ecode, out = b4.git_run_command(topdir, ['rev-parse', '--verify', value]) if ecode != 0: self.notify(f'Not a valid ref: {value}', severity='error') return @@ -2970,14 +3106,10 @@ class UpdateRevisionScreen(JKListNavMixin, ModalScreen[Optional[int]]): } """ - def __init__(self, current_revision: int, - revisions: List[Dict[str, Any]]) -> None: + def __init__(self, current_revision: int, revisions: List[Dict[str, Any]]) -> None: super().__init__() self._current_revision = current_revision - self._revisions = [ - r for r in revisions - if r['revision'] > current_revision - ] + self._revisions = [r for r in revisions if r['revision'] > current_revision] def compose(self) -> ComposeResult: with Vertical(id='update-rev-dialog'): @@ -2985,15 +3117,15 @@ class UpdateRevisionScreen(JKListNavMixin, ModalScreen[Optional[int]]): yield Static( f'Current revision: v{self._current_revision}\n' 'The current review branch will be archived.\n' - 'Reviews on unchanged patches will be preserved.') + 'Reviews on unchanged patches will be preserved.' + ) items = [] for r in self._revisions: subject = r.get('subject', '(no subject)') label = f'v{r["revision"]} {subject}' items.append(ListItem(Label(label, markup=False))) yield ListView(*items, id='update-rev-list') - yield Static('Enter confirm | Escape cancel', - id='update-rev-hint') + yield Static('Enter confirm | Escape cancel', id='update-rev-hint') def on_mount(self) -> None: self.query_one('#update-rev-list', ListView).focus() @@ -3115,12 +3247,12 @@ class TrackingCheckResultsScreen(ModalScreen[str]): } def __init__( - self, - title: str, - patch_labels: List[str], - patch_subjects: List[str], - tools: List[str], - matrix: Dict[Tuple[int, str], Dict[str, str]], + self, + title: str, + patch_labels: List[str], + patch_subjects: List[str], + tools: List[str], + matrix: Dict[Tuple[int, str], Dict[str, str]], ) -> None: """Create a check results modal. @@ -3141,12 +3273,24 @@ class TrackingCheckResultsScreen(ModalScreen[str]): def compose(self) -> ComposeResult: with Vertical(id='tcr-dialog'): yield Static(self._title, id='tcr-title', markup=False) - yield RichLog(id='tcr-matrix', highlight=False, wrap=False, - markup=True, auto_scroll=False) - yield RichLog(id='tcr-detail', highlight=False, wrap=True, - markup=True, auto_scroll=False) - yield Static(Text('[j/k] navigate [Enter] details [R] rerun [q] close'), - id='tcr-hint') + yield RichLog( + id='tcr-matrix', + highlight=False, + wrap=False, + markup=True, + auto_scroll=False, + ) + yield RichLog( + id='tcr-detail', + highlight=False, + wrap=True, + markup=True, + auto_scroll=False, + ) + yield Static( + Text('[j/k] navigate [Enter] details [R] rerun [q] close'), + id='tcr-hint', + ) def on_mount(self) -> None: self.query_one('#tcr-detail', RichLog).display = False @@ -3161,7 +3305,9 @@ class TrackingCheckResultsScreen(ModalScreen[str]): return # Compute column widths - label_w = max(len(lbl) for lbl in self._patch_labels) if self._patch_labels else 5 + label_w = ( + max(len(lbl) for lbl in self._patch_labels) if self._patch_labels else 5 + ) col_w = max(max(len(t) for t in self._tools), 8) ci_total = (col_w + 2) * len(self._tools) # pointer(2) + label + gap(2) + subject + gap(2) + ci_columns @@ -3180,16 +3326,18 @@ class TrackingCheckResultsScreen(ModalScreen[str]): # Data rows with cursor highlight for pidx, label in enumerate(self._patch_labels): - is_selected = (pidx == self._cursor_row) + is_selected = pidx == self._cursor_row row = Text(style='on grey27' if is_selected else '') pointer = '\u25b6 ' if is_selected else ' ' row.append(pointer) row.append(f'{label:>{label_w}s}', style='bold' if is_selected else '') row.append(' ') # Truncated subject - subj = self._patch_subjects[pidx] if pidx < len(self._patch_subjects) else '' + subj = ( + self._patch_subjects[pidx] if pidx < len(self._patch_subjects) else '' + ) if len(subj) > subj_w: - subj = subj[:subj_w - 1] + '\u2026' + subj = subj[: subj_w - 1] + '\u2026' row.append(f'{subj:<{subj_w}s} ', style='' if is_selected else 'dim') for tool in self._tools: cell = self._matrix.get((pidx, tool)) @@ -3206,8 +3354,7 @@ class TrackingCheckResultsScreen(ModalScreen[str]): # Scroll so the cursor row is visible (2 header lines + data rows) cursor_line = 2 + self._cursor_row - viewer.scroll_to(y=max(0, cursor_line - viewer.size.height // 2), - animate=False) + viewer.scroll_to(y=max(0, cursor_line - viewer.size.height // 2), animate=False) def _render_detail(self, pidx: int) -> None: detail = self.query_one('#tcr-detail', RichLog) @@ -3309,7 +3456,8 @@ class TrackingCheckResultsScreen(ModalScreen[str]): self.query_one('#tcr-detail', RichLog).display = False self.query_one('#tcr-matrix', RichLog).display = True self.query_one('#tcr-hint', Static).update( - Text('[j/k] navigate [Enter] details [R] rerun [q] close')) + Text('[j/k] navigate [Enter] details [R] rerun [q] close') + ) return self.dismiss('close') diff --git a/src/b4/review_tui/_pw_app.py b/src/b4/review_tui/_pw_app.py index 2b0c10a..6efb55a 100644 --- a/src/b4/review_tui/_pw_app.py +++ b/src/b4/review_tui/_pw_app.py @@ -36,9 +36,12 @@ from b4.review_tui._modals import ( ) -def _format_series_label(series: Dict[str, Any], tracked: bool, - ts: Optional[Dict[str, str]] = None, - show_delegate: bool = True) -> Text: +def _format_series_label( + series: Dict[str, Any], + tracked: bool, + ts: Optional[Dict[str, str]] = None, + show_delegate: bool = True, +) -> Text: """Build a Text label for a series row. *ts* is a resolved theme styles dict from :func:`resolve_styles`. @@ -46,12 +49,19 @@ def _format_series_label(series: Dict[str, Any], tracked: bool, """ track_mark = 'T' if tracked else ' ' ci_state = series.get('check') or 'pending' - ci_map = ci_styles(ts) if ts else { - 'pending': 'dim', 'success': 'green', 'warning': 'red', 'fail': 'bold red', - } + ci_map = ( + ci_styles(ts) + if ts + else { + 'pending': 'dim', + 'success': 'green', + 'warning': 'red', + 'fail': 'bold red', + } + ) ci_style = ci_map.get(ci_state, ci_map['pending']) date = (series.get('date') or '')[:10] - state = f"{(series.get('state') or 'new'):<15s}" + state = f'{(series.get("state") or "new"):<15s}' submitter = pad_display(series.get('submitter') or 'Unknown', 30) name = series.get('name') or '(no subject)' text = Text() @@ -70,8 +80,9 @@ class PwSeriesItem(ListItem): ACTION_REQUIRED_STATES = ('new', 'under-review') - def __init__(self, series: Dict[str, Any], tracked: bool = False, - show_delegate: bool = True) -> None: + def __init__( + self, series: Dict[str, Any], tracked: bool = False, show_delegate: bool = True + ) -> None: super().__init__() self.series = series self.tracked = tracked @@ -84,9 +95,12 @@ class PwSeriesItem(ListItem): def compose(self) -> ComposeResult: ts = resolve_styles(self.app) - yield Label(_format_series_label(self.series, self.tracked, ts, - show_delegate=self.show_delegate), - markup=False) + yield Label( + _format_series_label( + self.series, self.tracked, ts, show_delegate=self.show_delegate + ), + markup=False, + ) class PwApp(App[None]): @@ -162,9 +176,16 @@ class PwApp(App[None]): """ BINDING_GROUPS = { - 'view': 'Series', 'ci_checks': 'Series', 'track_series': 'Series', - 'set_state': 'Series', 'hide_series': 'Series', - 'refresh': 'App', 'limit': 'App', 'toggle_show_hidden': 'App', 'quit': 'App', 'help': 'App', + 'view': 'Series', + 'ci_checks': 'Series', + 'track_series': 'Series', + 'set_state': 'Series', + 'hide_series': 'Series', + 'refresh': 'App', + 'limit': 'App', + 'toggle_show_hidden': 'App', + 'quit': 'App', + 'help': 'App', } BINDINGS = [ @@ -185,8 +206,14 @@ class PwApp(App[None]): Binding('question_mark', 'help', 'help', key_display='?'), ] - def __init__(self, pwkey: str, pwurl: str, pwproj: str, - email_dryrun: bool = False, patatt_sign: bool = True) -> None: + def __init__( + self, + pwkey: str, + pwurl: str, + pwproj: str, + email_dryrun: bool = False, + patatt_sign: bool = True, + ) -> None: super().__init__() self._pwkey = pwkey self._pwurl = pwurl @@ -229,9 +256,13 @@ class PwApp(App[None]): if not self._tracking_identifier: # Fall back to patchwork project name self._tracking_identifier = self._pwproj - if self._tracking_identifier and b4.review.tracking.db_exists(self._tracking_identifier): + if self._tracking_identifier and b4.review.tracking.db_exists( + self._tracking_identifier + ): self._tracking_enabled = True - self._tracked_ids = b4.review.tracking.get_tracked_pw_series_ids(self._tracking_identifier) + self._tracked_ids = b4.review.tracking.get_tracked_pw_series_ids( + self._tracking_identifier + ) def _save_local_data(self) -> None: path = self._get_local_data_path() @@ -273,7 +304,9 @@ class PwApp(App[None]): elif event.state == WorkerState.ERROR: for widget in self.query('#pw-loading'): await widget.remove() - self.query_one('#pw-title', Static).update(' Patchwork — error fetching series') + self.query_one('#pw-title', Static).update( + ' Patchwork — error fetching series' + ) self.notify(str(event.worker.error), severity='error') async def _populate(self, series_list: List[Dict[str, Any]]) -> None: @@ -301,16 +334,23 @@ class PwApp(App[None]): visible.append((s, False)) if self._limit_pattern: visible = [ - (s, h) for s, h in visible + (s, h) + for s, h in visible if self._matches_limit(s, self._limit_pattern) ] limit_suffix = f', limit: {self._limit_pattern}' if self._limit_pattern else '' if hidden_count and not self._show_hidden: - title.update(f' Patchwork — {len(visible)} series ({hidden_count} hidden{limit_suffix})') + title.update( + f' Patchwork — {len(visible)} series ({hidden_count} hidden{limit_suffix})' + ) elif hidden_count and self._show_hidden: - title.update(f' Patchwork — {len(visible)} series (showing {hidden_count} hidden{limit_suffix})') + title.update( + f' Patchwork — {len(visible)} series (showing {hidden_count} hidden{limit_suffix})' + ) elif self._limit_pattern: - title.update(f' Patchwork — {len(visible)} action-required series{limit_suffix}') + title.update( + f' Patchwork — {len(visible)} action-required series{limit_suffix}' + ) else: title.update(f' Patchwork — {len(visible)} action-required series') if not visible: @@ -321,14 +361,15 @@ class PwApp(App[None]): if show_delegate: header_text = f' {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Delegate":<15s} {"Series"}' else: - header_text = f' {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Series"}' + header_text = ( + f' {"Date":<12s}{"State":<15s} {"Submitter":<30s} {"Series"}' + ) header = Static(header_text, id='pw-header') items = [] for s, is_hidden in visible: sid = s.get('id') is_tracked = sid in self._tracked_ids if sid else False - item = PwSeriesItem(s, tracked=is_tracked, - show_delegate=show_delegate) + item = PwSeriesItem(s, tracked=is_tracked, show_delegate=show_delegate) if is_hidden: item.add_class('--hidden') items.append(item) @@ -342,7 +383,9 @@ class PwApp(App[None]): for widget in self.query('#pw-header, #pw-list'): await widget.remove() self.query_one('#pw-title', Static).update(' Patchwork — refreshing\u2026') - await self.mount(LoadingIndicator(id='pw-loading'), before=self.query_one(Footer)) + await self.mount( + LoadingIndicator(id='pw-loading'), before=self.query_one(Footer) + ) self.run_worker(self._fetch_initial, name='_fetch_initial', thread=True) @staticmethod @@ -364,8 +407,10 @@ class PwApp(App[None]): if needle not in (series.get('delegate', '') or '').lower(): return False else: - if (token not in (series.get('name', '') or '').lower() - and token not in (series.get('submitter', '') or '').lower()): + if ( + token not in (series.get('name', '') or '').lower() + and token not in (series.get('submitter', '') or '').lower() + ): return False return True @@ -375,8 +420,9 @@ class PwApp(App[None]): hint = 'Prefixes: s:<state> d:<delegate>' else: hint = 'Prefixes: s:<state>' - self.push_screen(LimitScreen(self._limit_pattern, hint=hint), - callback=self._on_limit) + self.push_screen( + LimitScreen(self._limit_pattern, hint=hint), callback=self._on_limit + ) async def _on_limit(self, result: Optional[str]) -> None: if result is None: @@ -406,9 +452,12 @@ class PwApp(App[None]): self.notify('No message-id available for this series', severity='error') return from b4.review_tui._lite_app import LiteThreadScreen - self.push_screen(LiteThreadScreen(msgid, - email_dryrun=self._email_dryrun, - patatt_sign=self._patatt_sign)) + + self.push_screen( + LiteThreadScreen( + msgid, email_dryrun=self._email_dryrun, patatt_sign=self._patatt_sign + ) + ) def action_ci_checks(self) -> None: """View CI check details for the highlighted series.""" @@ -417,7 +466,9 @@ class PwApp(App[None]): return check = item.series.get('check') or 'pending' if check == 'pending': - self.notify('No CI checks available for this series', severity='information') + self.notify( + 'No CI checks available for this series', severity='information' + ) return self.push_screen(CIChecksScreen(self._pwkey, self._pwurl, item.series)) @@ -444,7 +495,9 @@ class PwApp(App[None]): callback=lambda result: self._on_set_state(result, item), ) - def _on_set_state(self, result: Optional[Tuple[str, bool]], item: 'PwSeriesItem') -> None: + def _on_set_state( + self, result: Optional[Tuple[str, bool]], item: 'PwSeriesItem' + ) -> None: if result is None: return new_state, archived = result @@ -456,13 +509,14 @@ class PwApp(App[None]): series_name = item.series.get('name', '(no subject)') self.push_screen( ApplyStateModal( - self._pwkey, self._pwurl, patch_ids, - new_state, archived, series_name + self._pwkey, self._pwurl, patch_ids, new_state, archived, series_name ), callback=lambda res: self._on_apply_complete(res, item), ) - def _on_apply_complete(self, result: Tuple[int, int, str], item: 'PwSeriesItem') -> None: + def _on_apply_complete( + self, result: Tuple[int, int, str], item: 'PwSeriesItem' + ) -> None: ok, fail, new_state = result if fail: self.notify(f'{ok} updated, {fail} failed', severity='warning') @@ -474,13 +528,18 @@ class PwApp(App[None]): else: item.add_class('--dimmed') ts = resolve_styles(self) - item.query_one(Label).update(_format_series_label( - item.series, item.tracked, ts, - show_delegate=item.show_delegate)) + item.query_one(Label).update( + _format_series_label( + item.series, item.tracked, ts, show_delegate=item.show_delegate + ) + ) def action_track_series(self) -> None: if not self._tracking_enabled: - self.notify('Repository not enrolled. Enroll with: b4 review enroll', severity='warning') + self.notify( + 'Repository not enrolled. Enroll with: b4 review enroll', + severity='warning', + ) return item = self._get_highlighted_item() if item is None: @@ -546,8 +605,17 @@ class PwApp(App[None]): assert self._tracking_identifier is not None conn = b4.review.tracking.get_db(self._tracking_identifier) b4.review.tracking.add_series_to_db( - conn, change_id, revision, subject, sender_name, sender_email, - sent_at, message_id, num_patches, pw_series_id) + conn, + change_id, + revision, + subject, + sender_name, + sender_email, + sent_at, + message_id, + num_patches, + pw_series_id, + ) conn.close() @@ -556,10 +624,14 @@ class PwApp(App[None]): item.tracked = True item.add_class('--tracked') ts = resolve_styles(self) - item.query_one(Label).update(_format_series_label( - item.series, True, ts, - show_delegate=item.show_delegate)) - self.notify(f'Started tracking: {series_name}', severity='information', timeout=3) + item.query_one(Label).update( + _format_series_label( + item.series, True, ts, show_delegate=item.show_delegate + ) + ) + self.notify( + f'Started tracking: {series_name}', severity='information', timeout=3 + ) async def action_hide_series(self) -> None: item = self._get_highlighted_item() @@ -642,4 +714,3 @@ class PwApp(App[None]): async def action_quit(self) -> None: self.exit() - diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py index 51004de..e476044 100644 --- a/src/b4/review_tui/_review_app.py +++ b/src/b4/review_tui/_review_app.py @@ -107,10 +107,6 @@ class FollowupItem(ListItem): yield st - - - - class ReviewApp(CheckRunnerMixin, App[None]): """Textual app for b4 review TUI.""" @@ -201,12 +197,21 @@ class ReviewApp(CheckRunnerMixin, App[None]): _EMAIL_ACTIONS = frozenset({'edit_tocc', 'send'}) BINDING_GROUPS = { - 'trailer': 'Review', 'edit_note': 'Review', - 'edit_reply': 'Review', 'followups': 'Review', 'agent': 'Review', + 'trailer': 'Review', + 'edit_note': 'Review', + 'edit_reply': 'Review', + 'followups': 'Review', + 'agent': 'Review', 'prior_review': 'Review', - 'patch_done': 'Review', 'patch_skip': 'Review', 'check': 'Review', - 'edit_tocc': 'Review', 'send': 'Review', - 'toggle_preview': 'App', 'suspend': 'App', 'quit': 'App', 'help': 'App', + 'patch_done': 'Review', + 'patch_skip': 'Review', + 'check': 'Review', + 'edit_tocc': 'Review', + 'send': 'Review', + 'toggle_preview': 'App', + 'suspend': 'App', + 'quit': 'App', + 'help': 'App', } BINDINGS = [ @@ -264,15 +269,21 @@ class ReviewApp(CheckRunnerMixin, App[None]): self._abbrev_len: int = session['abbrev_len'] self._default_identity: str = session['default_identity'] self._usercfg: b4.ConfigDictT = session['usercfg'] - self._reviewer_initials: str = _make_initials(session['usercfg'].get('name', '')) + self._reviewer_initials: str = _make_initials( + session['usercfg'].get('name', '') + ) self._cover_subject_clean: str = session['cover_subject_clean'] self._email_dryrun: bool = session.get('email_dryrun', False) self._patatt_sign: bool = session.get('patatt_sign', True) self._branch: str = session['branch'] self._original_branch: Optional[str] = session.get('original_branch') self.branch_checked_out: bool = False - self._has_cover: bool = 'NOTE: No cover letter provided by the author.' not in self._cover_text - self._selected_idx: int = 0 if self._has_cover else 1 # 0 = cover, 1..N = patches + self._has_cover: bool = ( + 'NOTE: No cover letter provided by the author.' not in self._cover_text + ) + self._selected_idx: int = ( + 0 if self._has_cover else 1 + ) # 0 = cover, 1..N = patches self._preview_mode: bool = False self._comment_positions: List[int] = [] self._followup_positions: Dict[str, int] = {} @@ -298,7 +309,13 @@ class ReviewApp(CheckRunnerMixin, App[None]): with Vertical(id='left-pane'): yield ListView(id='patch-list') yield Static(id='trailer-overlay', markup=False) - yield RichLog(id='diff-viewer', highlight=False, wrap=False, markup=True, auto_scroll=False) + yield RichLog( + id='diff-viewer', + highlight=False, + wrap=False, + markup=True, + auto_scroll=False, + ) yield SeparatedFooter() def on_mount(self) -> None: @@ -310,7 +327,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): switch_hint = self._session.get('_switch_hint') if switch_hint: self.notify( - f'You\'re in a review branch. To see all tracked series, switch to {switch_hint}.', + f"You're in a review branch. To see all tracked series, switch to {switch_hint}.", timeout=10, ) @@ -365,7 +382,11 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Patch entries for idx, _sha in enumerate(self._commit_shas): patch_num = idx + 1 - subject = self._commit_subjects[idx] if idx < len(self._commit_subjects) else '(unknown)' + subject = ( + self._commit_subjects[idx] + if idx < len(self._commit_subjects) + else '(unknown)' + ) patch_meta = self._patches[idx] if idx < len(self._patches) else {} state = b4.review._get_patch_state(patch_meta, self._usercfg) if self._hide_skipped and state == 'skip': @@ -387,6 +408,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): def _restore() -> None: lv.index = restore_index lv.scroll_visible() + self.call_after_refresh(_restore) def _append_followup_items(self, lv: ListView, display_idx: int) -> None: @@ -416,7 +438,11 @@ class ReviewApp(CheckRunnerMixin, App[None]): label_num = f'0/{total}' else: patch_idx = display_idx - 1 - subject = self._commit_subjects[patch_idx] if patch_idx < len(self._commit_subjects) else '(unknown)' + subject = ( + self._commit_subjects[patch_idx] + if patch_idx < len(self._commit_subjects) + else '(unknown)' + ) target = self._patches[patch_idx] if patch_idx < len(self._patches) else {} label_num = f'{display_idx}/{total}' subject = subject[:40] @@ -451,7 +477,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): self._refresh_trailer_overlay() def _build_msg_comment_map( - self, target: Dict[str, Any], ts: Dict[str, str], + self, + target: Dict[str, Any], + ts: Dict[str, str], ) -> Dict[int, List[Tuple[str, str, str]]]: """Build a comment map for COMMIT_MESSAGE_PATH comments. @@ -465,8 +493,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): colour = self._reviewer_colour(my_email, target, ts) for c in my_review.get('comments', []): if c['path'] == COMMIT_MESSAGE_PATH: - result.setdefault(c['line'], []).append( - ('You', colour, c['text'])) + result.setdefault(c['line'], []).append(('You', colour, c['text'])) return result def _show_cover(self, viewer: RichLog) -> None: @@ -475,7 +502,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): cover_lines = self._cover_text.strip().splitlines() # Render subject in accent colour, same as patches if cover_lines: - viewer.write(Text(cover_lines[0], style=f"bold {ts['accent']}")) + viewer.write(Text(cover_lines[0], style=f'bold {ts["accent"]}')) viewer.write(Text('')) body_lines = b4.review._strip_subject(self._cover_text) body = '\n'.join(body_lines) @@ -504,9 +531,12 @@ class ReviewApp(CheckRunnerMixin, App[None]): _write_followup_trailers(viewer, self._tracking.get('followups', []), ts=ts) # Show cover-level follow-up comments _write_followup_comments( - viewer, self._followup_comments.get(0, []), + viewer, + self._followup_comments.get(0, []), self._comment_positions, - header_position_map=self._followup_header_map, ts=ts) + header_position_map=self._followup_header_map, + ts=ts, + ) for line_pos, entry in self._followup_header_map.items(): msgid = str(entry.get('msgid', '')) if msgid: @@ -522,17 +552,20 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Show commit message with subject as a bright heading ecode, commit_msg = b4.git_run_command( - self._topdir, ['show', '--format=%B', '--no-patch', sha]) + self._topdir, ['show', '--format=%B', '--no-patch', sha] + ) if ecode == 0 and commit_msg.strip(): all_lines = commit_msg.strip().splitlines() # Render subject in accent colour if all_lines: - viewer.write(Text(all_lines[0], style=f"bold {ts['accent']}")) + viewer.write(Text(all_lines[0], style=f'bold {ts["accent"]}')) viewer.write(Text('')) body = '\n'.join(b4.review._strip_subject(commit_msg)) if body: # Build commit message comment map - patch_target_cm = self._patches[patch_idx] if patch_idx < len(self._patches) else {} + patch_target_cm = ( + self._patches[patch_idx] if patch_idx < len(self._patches) else {} + ) msg_comment_map = self._build_msg_comment_map(patch_target_cm, ts) # Render preamble comments (line 0 = before commit message) @@ -541,8 +574,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): self._comment_positions.append(len(viewer.lines)) _write_comments(viewer, preamble_entries, ts=ts) - bheaders, message, btrailers, _basement, _signature = \ + bheaders, message, btrailers, _basement, _signature = ( b4.LoreMessage.get_body_parts(body) + ) has_content = bool(preamble_entries) # Track line number through the body (1-based, after # subject and leading blanks — same as _build_annotated_diff) @@ -584,12 +618,15 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Show follow-up trailers not already in the commit, # including cover-letter trailers that apply to all patches - patch_meta = self._patches[patch_idx] if patch_idx < len(self._patches) else {} + patch_meta = ( + self._patches[patch_idx] if patch_idx < len(self._patches) else {} + ) existing = set() if btrailers: existing = {lt.as_string().lower() for lt in btrailers} - all_followups = (self._tracking.get('followups', []) - + patch_meta.get('followups', [])) + all_followups = self._tracking.get('followups', []) + patch_meta.get( + 'followups', [] + ) _write_followup_trailers(viewer, all_followups, existing, ts=ts) if all_followups: has_content = True @@ -614,7 +651,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): return # Get review comments — own comments always, external only on "f" - patch_target = self._patches[patch_idx] if patch_idx < len(self._patches) else {} + patch_target = ( + self._patches[patch_idx] if patch_idx < len(self._patches) else {} + ) all_reviews = patch_target.get('reviews', {}) my_email = str(self._usercfg.get('email', '')) comment_map: Dict[Tuple[str, int], List[Tuple[str, str, str]]] = {} @@ -631,7 +670,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): colour = self._reviewer_colour(rev_email, patch_target, ts) for c in rev_data.get('comments', []): key = (c['path'], c['line']) - comment_map.setdefault(key, []).append((rev_name, colour, c['text'])) + comment_map.setdefault(key, []).append( + (rev_name, colour, c['text']) + ) else: rev_name = rev_data.get('name', rev_email) for c in rev_data.get('comments', []): @@ -645,7 +686,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): current_b_file = '' a_line = 0 b_line = 0 - hint_style = f"bold {ts['warning']}" + hint_style = f'bold {ts["warning"]}' self._collapsed_comment_lines = {} def _write_hints(key: Tuple[str, int]) -> None: @@ -686,7 +727,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Colour only the @@...@@ marker, leave context in default end = line.index(' @@', 3) + 3 hunk_text = Text() - hunk_text.append(line[:end], style=f"bold {ts['secondary']}") + hunk_text.append(line[:end], style=f'bold {ts["secondary"]}') if len(line) > end: hunk_text.append(line[end:]) viewer.write(hunk_text) @@ -725,9 +766,12 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Render follow-up comments at the bottom _write_followup_comments( - viewer, self._followup_comments.get(patch_idx + 1, []), + viewer, + self._followup_comments.get(patch_idx + 1, []), self._comment_positions, - header_position_map=self._followup_header_map, ts=ts) + header_position_map=self._followup_header_map, + ts=ts, + ) for line_pos, entry in self._followup_header_map.items(): msgid = str(entry.get('msgid', '')) if msgid: @@ -747,7 +791,11 @@ class ReviewApp(CheckRunnerMixin, App[None]): else: review = {} patch_meta = None - commit_sha = self._commit_shas[patch_idx] if patch_idx < len(self._commit_shas) else None + commit_sha = ( + self._commit_shas[patch_idx] + if patch_idx < len(self._commit_shas) + else None + ) target = self._series if display_idx == 0 else patch_meta if target and b4.review._get_patch_state(target, self._usercfg) == 'skip': @@ -756,19 +804,27 @@ class ReviewApp(CheckRunnerMixin, App[None]): my_review = b4.review._get_my_review(target, self._usercfg) skip_reason = str(my_review.get('skip-reason', '')) if skip_reason: - viewer.write(f'[dim]Patch {label} is marked as skipped: {skip_reason}[/dim]') + viewer.write( + f'[dim]Patch {label} is marked as skipped: {skip_reason}[/dim]' + ) else: - viewer.write(f'[dim]Patch {label} is marked as skipped — no email will be sent.[/dim]') + viewer.write( + f'[dim]Patch {label} is marked as skipped — no email will be sent.[/dim]' + ) return - if not review or not (review.get('trailers') or review.get('reply', '') - or review.get('comments') or review.get('note', '')): + if not review or not ( + review.get('trailers') + or review.get('reply', '') + or review.get('comments') + or review.get('note', '') + ): viewer.write('[dim]No reply will be sent for this patch.[/dim]') return msg = b4.review._build_review_email( - self._series, patch_meta, review, self._cover_text, - self._topdir, commit_sha) + self._series, patch_meta, review, self._cover_text, self._topdir, commit_sha + ) if msg is None: viewer.write('[dim]No email to preview (missing message-id?).[/dim]') return @@ -801,8 +857,12 @@ class ReviewApp(CheckRunnerMixin, App[None]): text = Text() has_content = False for rev_email, review in ordered: - if not (review.get('trailers') or review.get('reply', '') - or review.get('comments') or review.get('note', '')): + if not ( + review.get('trailers') + or review.get('reply', '') + or review.get('comments') + or review.get('note', '') + ): continue colour = self._reviewer_colour(rev_email, target, ts) @@ -822,13 +882,20 @@ class ReviewApp(CheckRunnerMixin, App[None]): comments = review.get('comments', []) if comments: files: set[str] = set(c.get('path', '') for c in comments) - text.append(f'\n {len(comments)} comments across ' - f'{len(files)} files', style=ts['warning']) + text.append( + f'\n {len(comments)} comments across {len(files)} files', + style=ts['warning'], + ) reply = review.get('reply', '') if reply: - non_quoted = sum(1 for ln in reply.splitlines() - if ln.strip() and not ln.startswith('>')) - text.append(f'\n {non_quoted} non-quoted reply lines', style=ts['accent']) + non_quoted = sum( + 1 + for ln in reply.splitlines() + if ln.strip() and not ln.startswith('>') + ) + text.append( + f'\n {non_quoted} non-quoted reply lines', style=ts['accent'] + ) trailers = review.get('trailers', []) if trailers: for t in trailers: @@ -848,7 +915,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): if body_start is not None and body_start < len(lines): body_words = ' '.join(lines[body_start:]).split() if body_words: - text.append('\n (view full note with N)', style=ts['secondary']) + text.append( + '\n (view full note with N)', style=ts['secondary'] + ) if not has_content: overlay.display = False @@ -896,18 +965,27 @@ class ReviewApp(CheckRunnerMixin, App[None]): """Return the current user's review sub-dict, creating it if needed.""" return b4.review._ensure_my_review(target, self._usercfg) - def _reviewer_colour(self, email: str, target: Dict[str, Any], - ts: Optional[Dict[str, str]] = None) -> str: + def _reviewer_colour( + self, email: str, target: Dict[str, Any], ts: Optional[Dict[str, str]] = None + ) -> str: """Return a stable colour for a reviewer email. Current user always gets index 0; others are sorted by email and assigned cyclically from the rest of the palette. *ts* is a resolved theme styles dict from :func:`resolve_styles`. """ - palette = reviewer_colours(ts) if ts else [ - 'dark_goldenrod', 'dark_green', 'dark_cyan', - 'dark_magenta', 'dark_red', 'dark_blue', - ] + palette = ( + reviewer_colours(ts) + if ts + else [ + 'dark_goldenrod', + 'dark_green', + 'dark_cyan', + 'dark_magenta', + 'dark_red', + 'dark_blue', + ] + ) my_email = self._usercfg.get('email', '') if email == my_email: return palette[0] @@ -1007,7 +1085,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): return True if action == 'agent': config = b4.get_main_config() - if not config.get('review-agent-command') or not config.get('review-agent-prompt-path'): + if not config.get('review-agent-command') or not config.get( + 'review-agent-prompt-path' + ): return False return not self._preview_mode if action == 'prior_review': @@ -1034,7 +1114,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): target = self._get_current_review_target() if not target: return - existing_trailers = b4.review._get_my_review(target, self._usercfg).get('trailers', []) + existing_trailers = b4.review._get_my_review(target, self._usercfg).get( + 'trailers', [] + ) def _on_trailer(result: Optional[List[str]]) -> None: if result is None: @@ -1048,12 +1130,23 @@ class ReviewApp(CheckRunnerMixin, App[None]): new_trailers = [f'{name}: {self._default_identity}' for name in result] # If adding to a patch, filter out trailers already on the cover letter if self._selected_idx > 0 and new_trailers: - cover_trailers = b4.review._get_my_review(self._series, self._usercfg).get('trailers', []) - cover_names = {t.split(':', 1)[0].strip().lower() for t in cover_trailers} + cover_trailers = b4.review._get_my_review( + self._series, self._usercfg + ).get('trailers', []) + cover_names = { + t.split(':', 1)[0].strip().lower() for t in cover_trailers + } overlap = [r for r in result if r.lower() in cover_names] if overlap: - self.notify(f'{", ".join(overlap)} already on cover letter', severity='warning') - new_trailers = [t for t in new_trailers if t.split(':', 1)[0].strip().lower() not in cover_names] + self.notify( + f'{", ".join(overlap)} already on cover letter', + severity='warning', + ) + new_trailers = [ + t + for t in new_trailers + if t.split(':', 1)[0].strip().lower() not in cover_names + ] old_trailers: List[str] = review.get('trailers', []) if new_trailers == old_trailers: return @@ -1073,7 +1166,11 @@ class ReviewApp(CheckRunnerMixin, App[None]): pt = preview.get('trailers', []) if not pt: continue - remaining = [t for t in pt if t.split(':', 1)[0].strip().lower() not in new_names] + remaining = [ + t + for t in pt + if t.split(':', 1)[0].strip().lower() not in new_names + ] if remaining != pt: if remaining: preview['trailers'] = remaining @@ -1136,7 +1233,8 @@ class ReviewApp(CheckRunnerMixin, App[None]): return sha = self._commit_shas[patch_idx] ecode, real_diff = b4.git_run_command( - self._topdir, ['diff', f'{sha}~1', sha]) + self._topdir, ['diff', f'{sha}~1', sha] + ) if ecode > 0: self.notify('Could not get diff', severity='error') return @@ -1149,21 +1247,23 @@ class ReviewApp(CheckRunnerMixin, App[None]): if self._selected_idx == 0: # Cover letter reply editor_text = b4.review._render_quoted_diff_with_comments( - '', all_reviews, my_email, - commit_msg=self._cover_text) + '', all_reviews, my_email, commit_msg=self._cover_text + ) else: ecode, commit_msg = b4.git_run_command( - self._topdir, ['show', '--format=%B', '--no-patch', sha]) + self._topdir, ['show', '--format=%B', '--no-patch', sha] + ) if ecode > 0: self.notify('Could not get commit message', severity='error') return editor_text = b4.review._render_quoted_diff_with_comments( - real_diff, all_reviews, my_email, - commit_msg=commit_msg.strip()) + real_diff, all_reviews, my_email, commit_msg=commit_msg.strip() + ) with self.suspend(): result = b4.edit_in_editor( - editor_text.encode(), filehint='reply.b4-review.eml') + editor_text.encode(), filehint='reply.b4-review.eml' + ) if result is None: self.notify('Editor returned no content') @@ -1194,7 +1294,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Parse inline comments from the quoted reply # _extract_editor_comments strips | lines (unadopted external # comments) before parsing, so only adopted ones are kept - new_comments = b4.review._extract_editor_comments(reply_text, diff_text=real_diff) + new_comments = b4.review._extract_editor_comments( + reply_text, diff_text=real_diff + ) if new_comments: review['comments'] = new_comments if 'reply' in review: @@ -1213,13 +1315,11 @@ class ReviewApp(CheckRunnerMixin, App[None]): else: self.notify('Reply saved') - def action_prior_review(self) -> None: """Show prior revision review context.""" context = self._series.get('prior-review-context', '') if not context: - self.notify('No prior review context available', - severity='information') + self.notify('No prior review context available', severity='information') return self.push_screen(PriorReviewScreen(context)) @@ -1264,7 +1364,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): if result is None: return if result == '__EDIT__': - my_note = b4.review._get_my_review(target, self._usercfg).get('note', '') + my_note = b4.review._get_my_review(target, self._usercfg).get( + 'note', '' + ) self._edit_note_in_editor(target, my_note) elif result == '__DELETE__': self._delete_all_notes(target) @@ -1290,7 +1392,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): self.notify('Editor returned no content') return raw_text = result.decode(errors='replace') - note_text = '\n'.join(ln for ln in raw_text.splitlines() if not ln.startswith('#')).strip() + note_text = '\n'.join( + ln for ln in raw_text.splitlines() if not ln.startswith('#') + ).strip() if note_text == existing.strip(): self.notify('No changes made') return @@ -1321,8 +1425,12 @@ class ReviewApp(CheckRunnerMixin, App[None]): my_email = self._usercfg.get('email', '') for addr in list(all_reviews): rev = all_reviews[addr] - if not (rev.get('trailers') or rev.get('reply', '') - or rev.get('comments') or rev.get('note', '')): + if not ( + rev.get('trailers') + or rev.get('reply', '') + or rev.get('comments') + or rev.get('note', '') + ): if addr == my_email: b4.review._cleanup_review(target, self._usercfg) else: @@ -1390,7 +1498,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): self._refresh_patch_item(self._selected_idx) total = len(self._commit_shas) label = 'cover' if self._selected_idx == 0 else f'{self._selected_idx}/{total}' - self.notify(f'{label} marked as done' if new_state else f'{label} unmarked done') + self.notify( + f'{label} marked as done' if new_state else f'{label} unmarked done' + ) def action_patch_skip(self) -> None: """Toggle the explicit 'skip' state on the current patch.""" @@ -1425,7 +1535,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): self._selected_idx = 0 if self._has_cover else 1 self._populate_patch_list() self._show_content(self._selected_idx) - self.notify('Skipped patches hidden' if self._hide_skipped else 'Skipped patches shown') + self.notify( + 'Skipped patches hidden' if self._hide_skipped else 'Skipped patches shown' + ) def action_send(self) -> None: """Collect review emails and show send confirmation dialog.""" @@ -1443,12 +1555,17 @@ class ReviewApp(CheckRunnerMixin, App[None]): if draft_patches: self.notify( f'Still in draft: {", ".join(draft_patches)}. Mark as done (d) or skip (x) first.', - severity='warning') + severity='warning', + ) return msgs = b4.review.collect_review_emails( - self._series, self._patches, self._cover_text, - self._topdir, self._commit_shas) + self._series, + self._patches, + self._cover_text, + self._topdir, + self._commit_shas, + ) if not msgs: self.notify('No review data to send.') return @@ -1459,9 +1576,15 @@ class ReviewApp(CheckRunnerMixin, App[None]): try: with self.suspend(): smtp, fromaddr = b4.get_smtp(dryrun=self._email_dryrun) - sent = b4.send_mail(smtp, msgs, fromaddr=fromaddr, - patatt_sign=self._patatt_sign, dryrun=self._email_dryrun, - output_dir=None, reflect=False) + sent = b4.send_mail( + smtp, + msgs, + fromaddr=fromaddr, + patatt_sign=self._patatt_sign, + dryrun=self._email_dryrun, + output_dir=None, + reflect=False, + ) if sent is None: self.notify('Failed to send review emails.', severity='error') else: @@ -1508,8 +1631,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): self._compose_followup_reply(entry) event.stop() - def _compose_followup_reply(self, entry: Dict[str, Any], - initial_text: Optional[str] = None) -> None: + def _compose_followup_reply( + self, entry: Dict[str, Any], initial_text: Optional[str] = None + ) -> None: """Compose a reply to a follow-up message using the external editor. If *initial_text* is given (re-edit loop), use it directly instead of @@ -1545,9 +1669,15 @@ class ReviewApp(CheckRunnerMixin, App[None]): try: with self.suspend(): smtp, fromaddr = b4.get_smtp(dryrun=self._email_dryrun) - sent = b4.send_mail(smtp, [msg], fromaddr=fromaddr, - patatt_sign=self._patatt_sign, dryrun=self._email_dryrun, - output_dir=None, reflect=False) + sent = b4.send_mail( + smtp, + [msg], + fromaddr=fromaddr, + patatt_sign=self._patatt_sign, + dryrun=self._email_dryrun, + output_dir=None, + reflect=False, + ) if sent is None: self.notify('Failed to send reply.', severity='error') elif self._email_dryrun: @@ -1594,7 +1724,8 @@ class ReviewApp(CheckRunnerMixin, App[None]): all_followups = lmbx.followups + lmbx.unknowns for lmsg in sorted(all_followups, key=lambda m: m.date): display_idx = _resolve_patch_for_followup( - lmsg.in_reply_to, patch_msgids, lmbx.msgid_map) + lmsg.in_reply_to, patch_msgids, lmbx.msgid_map + ) if display_idx is None: continue @@ -1609,8 +1740,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): continue # minimize_thread strips trailers from the body; re-append # them so the follow-up panel shows the full message. - _htrs, _cmsg, mtrs, _basement, _sig = ( - b4.LoreMessage.get_body_parts(lmsg.body)) + _htrs, _cmsg, mtrs, _basement, _sig = b4.LoreMessage.get_body_parts( + lmsg.body + ) if mtrs: trailer_block = '\n'.join(t.as_string() for t in mtrs) mbody = mbody.rstrip('\n') + '\n\n' + trailer_block @@ -1623,10 +1755,13 @@ class ReviewApp(CheckRunnerMixin, App[None]): 'msgid': lmsg.msgid, 'subject': lmsg.full_subject, 'reply': lmsg.reply, - 'depth': _get_followup_depth(lmsg.in_reply_to, patch_msgids, lmbx.msgid_map), + 'depth': _get_followup_depth( + lmsg.in_reply_to, patch_msgids, lmbx.msgid_map + ), 'lmsg': lmsg, 'replies-to-diff': _chain_has_additional_patch( - lmsg.in_reply_to, patch_msgids, lmbx.msgid_map), + lmsg.in_reply_to, patch_msgids, lmbx.msgid_map + ), } self._followup_comments.setdefault(display_idx, []).append(entry) count += 1 @@ -1710,7 +1845,8 @@ class ReviewApp(CheckRunnerMixin, App[None]): sha = self._commit_shas[idx] ecode, real_diff = b4.git_run_command( - self._topdir, ['diff', f'{sha}~1', sha]) + self._topdir, ['diff', f'{sha}~1', sha] + ) if ecode == 0: b4.review._resolve_comment_positions(real_diff, comments) @@ -1753,9 +1889,13 @@ class ReviewApp(CheckRunnerMixin, App[None]): self.notify('Loading follow-ups\u2026') self.run_worker( lambda: self._fetch_followups_bg(cover_msgid, blob_sha), - name='_followup_worker', thread=True) + name='_followup_worker', + thread=True, + ) - def _fetch_rethreaded_threads(self, blob_sha: str) -> Optional[List[email.message.EmailMessage]]: + def _fetch_rethreaded_threads( + self, blob_sha: str + ) -> Optional[List[email.message.EmailMessage]]: """Fetch threads for each real patch in a rethreaded series.""" all_msgs: List[email.message.EmailMessage] = [] seen_msgids: Set[str] = set() @@ -1765,8 +1905,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): if not pmsgid or pmsgid in fetched_patches: continue fetched_patches.add(pmsgid) - thread = get_thread_msgs(self._topdir, pmsgid, - blob_sha=blob_sha, quiet=True) + thread = get_thread_msgs( + self._topdir, pmsgid, blob_sha=blob_sha, quiet=True + ) if thread: for msg in thread: c_msgid = b4.LoreMessage.get_clean_msgid(msg) @@ -1782,15 +1923,18 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Rethreaded series: fetch each real patch's thread msgs = self._fetch_rethreaded_threads(blob_sha) else: - msgs = get_thread_msgs(self._topdir, cover_msgid, - blob_sha=blob_sha, quiet=True) + msgs = get_thread_msgs( + self._topdir, cover_msgid, blob_sha=blob_sha, quiet=True + ) if not msgs: + def _no_msgs() -> None: self.notify('Could not load thread', severity='error') # Still refresh to show external comments (sashiko etc.) self._populate_patch_list() self._show_content(self._selected_idx) + self.app.call_from_thread(_no_msgs) return @@ -1800,7 +1944,8 @@ class ReviewApp(CheckRunnerMixin, App[None]): if change_id: with _quiet_worker(): new_sha = b4.review.tracking._store_thread_blob( - self._topdir, change_id, msgs) + self._topdir, change_id, msgs + ) if new_sha: self._series['thread-blob'] = new_sha @@ -1808,6 +1953,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): self._load_followup_msgs(msgs) self._mark_followup_msgs_seen(msgs) self._detect_maintainer_replies(msgs) + self.app.call_from_thread(_finish) def _mark_followup_msgs_seen(self, msgs: List[Any]) -> None: @@ -1821,7 +1967,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): msg_date = None if date_val: try: - msg_date = email.utils.parsedate_to_datetime(str(date_val)).isoformat() + msg_date = email.utils.parsedate_to_datetime( + str(date_val) + ).isoformat() except Exception: pass entries.append({'msgid': mid, 'msg_date': msg_date}) @@ -1829,6 +1977,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): return try: from b4.review import messages + conn = messages.get_db() messages.set_flags_bulk(conn, entries, 'Seen') conn.close() @@ -1848,6 +1997,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): return try: from b4.review import messages + conn = messages.get_db() messages.set_flags_bulk(conn, entries, 'Answered') conn.close() @@ -1895,6 +2045,7 @@ class ReviewApp(CheckRunnerMixin, App[None]): return try: from b4.review import messages + conn = messages.get_db() messages.set_flags_bulk(conn, answered_entries, 'Answered') conn.close() @@ -1912,7 +2063,8 @@ class ReviewApp(CheckRunnerMixin, App[None]): if self.branch_checked_out: return True ecode, _out = b4.git_run_command( - self._topdir, ['checkout', self._branch], logstderr=True) + self._topdir, ['checkout', self._branch], logstderr=True + ) if ecode != 0: self.notify(f'Could not check out {self._branch}', severity='error') return False @@ -1924,7 +2076,8 @@ class ReviewApp(CheckRunnerMixin, App[None]): if not self.branch_checked_out or not self._original_branch: return ecode, _out = b4.git_run_command( - self._topdir, ['checkout', self._original_branch], logstderr=True) + self._topdir, ['checkout', self._original_branch], logstderr=True + ) if ecode == 0: self.branch_checked_out = False @@ -1936,8 +2089,10 @@ class ReviewApp(CheckRunnerMixin, App[None]): agent_cmd = config.get('review-agent-command') agent_prompt = str(config.get('review-agent-prompt-path', '')) if not agent_cmd or not agent_prompt: - self.notify('Review agent not configured (set b4.review-agent-command and b4.review-agent-prompt-path)', - severity='warning') + self.notify( + 'Review agent not configured (set b4.review-agent-command and b4.review-agent-prompt-path)', + severity='warning', + ) return assert isinstance(agent_cmd, str) @@ -1947,8 +2102,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): prompt_path = os.path.join(self._topdir, agent_prompt) if not os.path.isfile(prompt_path): - self.notify(f'Agent prompt file not found: {agent_prompt}', - severity='error') + self.notify( + f'Agent prompt file not found: {agent_prompt}', severity='error' + ) return cmdargs += [f'Read and execute the prompt from {prompt_path}'] @@ -1976,8 +2132,12 @@ class ReviewApp(CheckRunnerMixin, App[None]): # Integrate any review files the agent wrote integrated = b4.review._integrate_agent_reviews( - self._topdir, self._cover_text, self._tracking, - self._commit_shas, self._patches) + self._topdir, + self._cover_text, + self._tracking, + self._commit_shas, + self._patches, + ) if integrated: self._populate_patch_list() self._show_content(self._selected_idx) @@ -1986,7 +2146,9 @@ class ReviewApp(CheckRunnerMixin, App[None]): def _save_tracking(self) -> None: """Save tracking data to the review branch.""" - b4.review.save_tracking_ref(self._topdir, self._branch, self._cover_text, self._tracking) + b4.review.save_tracking_ref( + self._topdir, self._branch, self._cover_text, self._tracking + ) def action_suspend(self) -> None: """Suspend the TUI and drop to an interactive shell.""" @@ -2014,11 +2176,13 @@ class ReviewApp(CheckRunnerMixin, App[None]): range_spec = f'{self._base_commit}..HEAD~1' ecode, out = b4.git_run_command( - self._topdir, ['rev-list', '--reverse', range_spec]) + self._topdir, ['rev-list', '--reverse', range_spec] + ) if ecode != 0 or not out.strip(): # Could not enumerate — tracking commit may be damaged - self.notify('Could not enumerate patch commits after shell', - severity='warning') + self.notify( + 'Could not enumerate patch commits after shell', severity='warning' + ) return new_shas = out.strip().splitlines() @@ -2030,16 +2194,19 @@ class ReviewApp(CheckRunnerMixin, App[None]): self.notify( f'Patch count changed ({len(old_shas)} → {len(new_shas)}). ' 'Please exit and re-enter the review.', - severity='warning') + severity='warning', + ) return # Reload tracking from the (possibly rewritten) tip commit try: self._cover_text, self._tracking = b4.review.load_tracking( - self._topdir, 'HEAD') + self._topdir, 'HEAD' + ) except SystemExit: - self.notify('Could not reload tracking data after shell', - severity='warning') + self.notify( + 'Could not reload tracking data after shell', severity='warning' + ) return series = self._tracking.get('series', {}) @@ -2050,23 +2217,24 @@ class ReviewApp(CheckRunnerMixin, App[None]): series['first-patch-commit'] = new_shas[0] # Re-anchor inline comments against the rebased diffs - b4.review.reanchor_patch_comments( - self._topdir, new_shas, self._patches) + b4.review.reanchor_patch_comments(self._topdir, new_shas, self._patches) # Persist updated tracking self._tracking['series'] = series b4.review.save_tracking_ref( - self._topdir, self._branch, self._cover_text, self._tracking) + self._topdir, self._branch, self._cover_text, self._tracking + ) # Refresh in-memory state self._commit_shas = new_shas ecode, out = b4.git_run_command( - self._topdir, ['log', '--reverse', '--format=%s', range_spec]) + self._topdir, ['log', '--reverse', '--format=%s', range_spec] + ) if ecode == 0 and out.strip(): self._commit_subjects = out.strip().splitlines() self._sha_map = {} for idx, full_sha in enumerate(new_shas): - self._sha_map[full_sha[:self._abbrev_len]] = (full_sha, idx) + self._sha_map[full_sha[: self._abbrev_len]] = (full_sha, idx) # Refresh the patch list display self._populate_patch_list() @@ -2080,6 +2248,8 @@ class ReviewApp(CheckRunnerMixin, App[None]): def action_help(self) -> None: """Show help overlay.""" config = b4.get_main_config() - has_agent = bool(config.get('review-agent-command') and config.get('review-agent-prompt-path')) + has_agent = bool( + config.get('review-agent-command') + and config.get('review-agent-prompt-path') + ) self.push_screen(HelpScreen(_review_help_lines(has_agent=has_agent))) - diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py index 2493cda..2c8087f 100644 --- a/src/b4/review_tui/_tracking_app.py +++ b/src/b4/review_tui/_tracking_app.py @@ -86,15 +86,15 @@ _ACTION_SHORTCUTS: Dict[str, str] = { # Single-character Unicode symbols for each series status. _STATUS_SYMBOLS: Dict[str, str] = { - 'new': '★', # U+2605 black star + 'new': '★', # U+2605 black star 'reviewing': '✎', # U+270E lower right pencil (matches review app) - 'replied': '↩', # U+21A9 leftwards arrow with hook - 'waiting': '↻', # U+21BB clockwise open circle arrow - 'accepted': '∈', # U+2208 element of - 'queued': '◷', # U+25F7 white circle with upper right quadrant - 'snoozed': '⏸', # U+23F8 double vertical bar - 'thanked': '✓', # U+2713 check mark - 'gone': 'ø', # U+00F8 latin small letter o with stroke + 'replied': '↩', # U+21A9 leftwards arrow with hook + 'waiting': '↻', # U+21BB clockwise open circle arrow + 'accepted': '∈', # U+2208 element of + 'queued': '◷', # U+25F7 white circle with upper right quadrant + 'snoozed': '⏸', # U+23F8 double vertical bar + 'thanked': '✓', # U+2713 check mark + 'gone': 'ø', # U+00F8 latin small letter o with stroke } # Sort tier for each status. Lower tier sorts higher in the list. @@ -102,21 +102,26 @@ _STATUS_SYMBOLS: Dict[str, str] = { # 1 = action required (awaiting maintainer decision) # 2 = inactive (no action needed) _STATUS_TIER: Dict[str, int] = { - 'new': 0, + 'new': 0, 'reviewing': 0, - 'replied': 1, - 'accepted': 1, - 'queued': 2, - 'snoozed': 2, - 'waiting': 2, - 'thanked': 2, - 'gone': 2, + 'replied': 1, + 'accepted': 1, + 'queued': 2, + 'snoozed': 2, + 'waiting': 2, + 'thanked': 2, + 'gone': 2, } # Statuses where the maintainer can take action right now. -_ACTIONABLE_STATUSES: frozenset[str] = frozenset({ - 'new', 'reviewing', 'replied', 'accepted', -}) +_ACTIONABLE_STATUSES: frozenset[str] = frozenset( + { + 'new', + 'reviewing', + 'replied', + 'accepted', + } +) def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> bool: @@ -135,20 +140,32 @@ def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> boo logger.critical('---') logger.critical('Patch did not apply cleanly.') # Disable sparse checkout so user can see and edit files - b4.git_run_command(cex.worktree_path, ['sparse-checkout', 'disable'], - logstderr=True, rundir=cex.worktree_path) + b4.git_run_command( + cex.worktree_path, + ['sparse-checkout', 'disable'], + logstderr=True, + rundir=cex.worktree_path, + ) # Save worktree HEAD before shell so we can detect abort _ecode, wt_head_before = b4.git_run_command( - cex.worktree_path, ['rev-parse', 'HEAD'], - logstderr=True, rundir=cex.worktree_path) + cex.worktree_path, + ['rev-parse', 'HEAD'], + logstderr=True, + rundir=cex.worktree_path, + ) wt_head_before = wt_head_before.strip() logger.info('You can resolve the conflict in the worktree.') - logger.info('Use "git am --continue" after resolving, or "git am --abort" to give up.') + logger.info( + 'Use "git am --continue" after resolving, or "git am --abort" to give up.' + ) _suspend_to_shell(hint='b4 conflict', cwd=cex.worktree_path) # Check if am is still in progress (user exited without finishing) ecode, wt_gitdir = b4.git_run_command( - cex.worktree_path, ['rev-parse', '--git-dir'], - logstderr=True, rundir=cex.worktree_path) + cex.worktree_path, + ['rev-parse', '--git-dir'], + logstderr=True, + rundir=cex.worktree_path, + ) if ecode == 0: rebase_apply = os.path.join(wt_gitdir.strip(), 'rebase-apply') else: @@ -159,15 +176,20 @@ def _resolve_worktree_am_conflict(topdir: str, cex: 'b4.AmConflictError') -> boo return False # Check if am was aborted (HEAD unchanged from before shell) _ecode, wt_head_after = b4.git_run_command( - cex.worktree_path, ['rev-parse', 'HEAD'], - logstderr=True, rundir=cex.worktree_path) + cex.worktree_path, + ['rev-parse', 'HEAD'], + logstderr=True, + rundir=cex.worktree_path, + ) if wt_head_after.strip() == wt_head_before: logger.warning('Conflict resolution aborted') b4.git_run_command(topdir, ['worktree', 'remove', '--force', cex.worktree_path]) return False # am completed -- fetch result into FETCH_HEAD logger.info('Conflict resolved, fetching result...') - ecode, _out = b4.git_run_command(topdir, ['fetch', cex.worktree_path], logstderr=True) + ecode, _out = b4.git_run_command( + topdir, ['fetch', cex.worktree_path], logstderr=True + ) b4.git_run_command(topdir, ['worktree', 'remove', '--force', cex.worktree_path]) if ecode > 0: logger.critical('Unable to fetch from resolved worktree') @@ -257,8 +279,11 @@ def _get_review_branch_tips(topdir: str) -> Dict[str, str]: Uses a single git for-each-ref call instead of per-branch rev-parse. """ - gitargs = ['for-each-ref', '--format=%(refname:short) %(objectname)', - 'refs/heads/b4/review/'] + gitargs = [ + 'for-each-ref', + '--format=%(refname:short) %(objectname)', + 'refs/heads/b4/review/', + ] lines = b4.git_get_command_lines(topdir, gitargs) result: Dict[str, str] = {} for line in lines: @@ -314,7 +339,7 @@ def _get_art_counts_batch( msg_start = content.find('\n\n') if msg_start < 0: continue - commit_msg = content[msg_start + 2:] + commit_msg = content[msg_start + 2 :] art = _parse_art_from_message(commit_msg) if art is not None: @@ -447,7 +472,7 @@ class TrackedSeriesItem(ListItem): badge_style = '' if base_accent or fu_badge: ts = resolve_styles(self.app) - accent = f"bold {ts['warning']}" + accent = f'bold {ts["warning"]}' if base_accent: base_style = accent if fu_badge: @@ -552,11 +577,20 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): """ BINDING_GROUPS = { - 'review': 'Series', 'check': 'Series', 'thread': 'Series', - 'range_diff': 'Series', 'action': 'Series', 'update_one': 'Series', + 'review': 'Series', + 'check': 'Series', + 'thread': 'Series', + 'range_diff': 'Series', + 'action': 'Series', + 'update_one': 'Series', 'target_branch': 'Series', - 'update_all': 'App', 'process_queue': 'App', 'limit': 'App', - 'suspend': 'App', 'patchwork': 'App', 'quit': 'App', 'help': 'App', + 'update_all': 'App', + 'process_queue': 'App', + 'limit': 'App', + 'suspend': 'App', + 'patchwork': 'App', + 'quit': 'App', + 'help': 'App', } BINDINGS = [ @@ -582,10 +616,14 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): Binding('question_mark', 'help', 'help', key_display='?'), ] - def __init__(self, identifier: str, original_branch: Optional[str] = None, - focus_change_id: Optional[str] = None, - email_dryrun: bool = False, - patatt_sign: bool = True) -> None: + def __init__( + self, + identifier: str, + original_branch: Optional[str] = None, + focus_change_id: Optional[str] = None, + email_dryrun: bool = False, + patatt_sign: bool = True, + ) -> None: super().__init__() self._identifier = identifier self._original_branch = original_branch @@ -610,7 +648,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._queue_count: int = 0 # Show target branch binding only when configured self._has_target_branches = bool( - b4.review.tracking.get_review_target_branches()) + b4.review.tracking.get_review_target_branches() + ) # Cached data for _load_series — invalidated by _invalidate_caches() # when u/U update runs or actions change tracking data. self._cached_branch_tips: Optional[Dict[str, str]] = None @@ -638,8 +677,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._cached_revisions = None self._cached_art_counts = None - def _refresh_msg_count(self, series: Dict[str, Any], - total_messages: int) -> None: + def _refresh_msg_count(self, series: Dict[str, Any], total_messages: int) -> None: """Opportunistically refresh message count after fetching messages.""" if not self._identifier: return @@ -697,8 +735,11 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self.set_interval(1, self._check_db_changed) topdir = b4.git_get_toplevel() if topdir and b4.review.tracking.db_exists(self._identifier): - self.run_worker(lambda: self._startup_rescan(topdir), - name='_startup_rescan', thread=True) + self.run_worker( + lambda: self._startup_rescan(topdir), + name='_startup_rescan', + thread=True, + ) def _startup_rescan(self, topdir: str) -> Dict[str, int]: """Rescan review branches in the background on app startup.""" @@ -772,9 +813,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): finally: conn.close() - def _wake_one(self, conn: 'sqlite3.Connection', - entry: Dict[str, Any], - topdir: Optional[str]) -> None: + def _wake_one( + self, conn: 'sqlite3.Connection', entry: Dict[str, Any], topdir: Optional[str] + ) -> None: """Restore a single snoozed series to its previous state.""" cid = entry['change_id'] rev = entry['revision'] @@ -789,6 +830,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): def _load_series(self) -> None: import b4.ty + self._auto_wake_snoozed() all_series = b4.review.tracking.get_all_tracked_series(self._identifier) @@ -815,9 +857,15 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): conn = None if self._cached_newest_revisions is None and conn: try: - self._cached_newest_revisions = b4.review.tracking.get_all_newest_revisions(conn) - self._cached_revision_counts = b4.review.tracking.get_all_revision_counts(conn) - self._cached_revisions = b4.review.tracking.get_all_revisions_grouped(conn) + self._cached_newest_revisions = ( + b4.review.tracking.get_all_newest_revisions(conn) + ) + self._cached_revision_counts = ( + b4.review.tracking.get_all_revision_counts(conn) + ) + self._cached_revisions = b4.review.tracking.get_all_revisions_grouped( + conn + ) except Exception: pass newest_revisions = self._cached_newest_revisions or {} @@ -843,7 +891,11 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): rev_count = revision_counts.get(change_id, 0) if rev_count > 1: series['has_multiple_revisions'] = True - if rev_count == 0 and series.get('status') not in ('new', 'gone', 'snoozed'): + if rev_count == 0 and series.get('status') not in ( + 'new', + 'gone', + 'snoozed', + ): series['needs_update'] = True # Stash revisions list for the detail panel series['_revisions'] = all_revisions.get(change_id, []) @@ -867,8 +919,10 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # This is a display-only pseudo-state, not stored in the DB. queued_cids = b4.ty.get_queued_change_ids(dryrun=self._email_dryrun) for series in self._all_series: - if (series.get('status') == 'accepted' - and series.get('change_id', '') in queued_cids): + if ( + series.get('status') == 'accepted' + and series.get('change_id', '') in queued_cids + ): series['queued'] = True # Sort into three tiers: active → action required → inactive. # Within each tier, sort by when the maintainer started tracking @@ -879,7 +933,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): ) self._all_series.sort( key=lambda s: _STATUS_TIER.get( - 'queued' if s.get('queued') else s.get('status', 'new'), 2) + 'queued' if s.get('queued') else s.get('status', 'new'), 2 + ) ) self.call_later(self._refresh_list) @@ -919,8 +974,10 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if needle not in (series.get('target_branch', '') or '').lower(): return False else: - if (token not in (series.get('subject', '') or '').lower() - and token not in (series.get('sender_name', '') or '').lower()): + if ( + token not in (series.get('subject', '') or '').lower() + and token not in (series.get('sender_name', '') or '').lower() + ): return False return True @@ -928,8 +985,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): display_series = self._all_series if self._limit_pattern: display_series = [ - s for s in display_series - if self._matches_limit(s, self._limit_pattern) + s for s in display_series if self._matches_limit(s, self._limit_pattern) ] try: @@ -953,11 +1009,16 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # the new list is mounted. with self.app.batch_update(): # Remove existing list/empty widgets - for widget in list(self.query('#tracking-header, #tracking-list, #tracking-empty')): + for widget in list( + self.query('#tracking-header, #tracking-list, #tracking-empty') + ): await widget.remove() if not display_series: - empty = Static('No tracked series. Use "b4 review track" to add series.', id='tracking-empty') + empty = Static( + 'No tracked series. Use "b4 review track" to add series.', + id='tracking-empty', + ) await self.mount(empty, before=self.query_one(Footer)) return @@ -971,7 +1032,10 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if self._focus_change_id: for idx, item in enumerate(list_items): - if isinstance(item, TrackedSeriesItem) and item.series.get('change_id') == self._focus_change_id: + if ( + isinstance(item, TrackedSeriesItem) + and item.series.get('change_id') == self._focus_change_id + ): lv.index = idx break self._focus_change_id = None @@ -985,9 +1049,12 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._show_details(highlighted.series) def action_limit(self) -> None: - self.push_screen(LimitScreen(self._limit_pattern, - hint='Prefixes: s:<status> t:<target-branch>'), - callback=self._on_limit) + self.push_screen( + LimitScreen( + self._limit_pattern, hint='Prefixes: s:<status> t:<target-branch>' + ), + callback=self._on_limit, + ) def _on_limit(self, result: Optional[str]) -> None: if result is None: @@ -1028,14 +1095,42 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): else: self.action_action() - _STATE_ACTIONS: Dict[str, frozenset[str]] = { - 'new': frozenset({'review', 'range_diff', 'abandon', 'snooze', 'waiting', 'target_branch'}), - 'reviewing': frozenset({'review', 'update_revision', 'range_diff', 'take', 'rebase', 'abandon', 'waiting', 'snooze', 'target_branch'}), - 'replied': frozenset({'review', 'range_diff', 'take', 'rebase', 'archive', 'waiting', 'snooze', 'target_branch'}), - 'waiting': frozenset({'review', 'range_diff', 'abandon', 'archive', 'snooze', 'target_branch'}), + 'new': frozenset( + {'review', 'range_diff', 'abandon', 'snooze', 'waiting', 'target_branch'} + ), + 'reviewing': frozenset( + { + 'review', + 'update_revision', + 'range_diff', + 'take', + 'rebase', + 'abandon', + 'waiting', + 'snooze', + 'target_branch', + } + ), + 'replied': frozenset( + { + 'review', + 'range_diff', + 'take', + 'rebase', + 'archive', + 'waiting', + 'snooze', + 'target_branch', + } + ), + 'waiting': frozenset( + {'review', 'range_diff', 'abandon', 'archive', 'snooze', 'target_branch'} + ), 'accepted': frozenset({'review', 'range_diff', 'thank', 'archive'}), - 'snoozed': frozenset({'review', 'range_diff', 'unsnooze', 'abandon', 'target_branch'}), + 'snoozed': frozenset( + {'review', 'range_diff', 'unsnooze', 'abandon', 'target_branch'} + ), 'thanked': frozenset({'archive'}), 'gone': frozenset({'abandon', 'review'}), } @@ -1147,15 +1242,19 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): pass if conn: b4.review.tracking.unsnooze_series( - conn, change_id, 'reviewing', revision=revision) + conn, change_id, 'reviewing', revision=revision + ) elif status in ('waiting', 'accepted'): # Bring back to reviewing on re-entry if conn: b4.review.tracking.update_series_status( - conn, change_id, 'reviewing', revision=revision) + conn, change_id, 'reviewing', revision=revision + ) topdir = b4.git_get_toplevel() if topdir: - b4.review.update_tracking_status(topdir, branch_name, 'reviewing') + b4.review.update_tracking_status( + topdir, branch_name, 'reviewing' + ) # Clear the followup badge — user is about to read this series if conn and self._identifier and isinstance(revision, int): b4.review.tracking.mark_all_messages_seen(conn, change_id, revision) @@ -1210,8 +1309,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): rev_info = rev break if rev_info is None: - self.notify(f'Revision v{chosen} not found in database', - severity='error') + self.notify( + f'Revision v{chosen} not found in database', severity='error' + ) return series['message_id'] = rev_info['message_id'] series['revision'] = chosen @@ -1219,8 +1319,13 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: conn = b4.review.tracking.get_db(self._identifier) b4.review.tracking.update_series_revision( - conn, change_id, current_rev, chosen, - rev_info['message_id'], rev_info.get('subject')) + conn, + change_id, + current_rev, + chosen, + rev_info['message_id'], + rev_info.get('subject'), + ) conn.close() except Exception: pass @@ -1243,10 +1348,15 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): } self._focus_change_id = self._selected_series.get('change_id') from b4.review_tui._lite_app import LiteThreadScreen - self.push_screen(LiteThreadScreen(message_id, - email_dryrun=self._email_dryrun, - patatt_sign=self._patatt_sign, - tracking_info=tracking_info)) + + self.push_screen( + LiteThreadScreen( + message_id, + email_dryrun=self._email_dryrun, + patatt_sign=self._patatt_sign, + tracking_info=tracking_info, + ) + ) def _checkout_new_series(self) -> None: """Retrieve series, build am-ready mbox, and show base selection.""" @@ -1271,21 +1381,24 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # The stored message-id may point to a different # version's thread. Search for the wanted version # in other threads before giving up. - msgs = b4.mbox.get_extra_series( - msgs, direction=1, nocache=True) + msgs = b4.mbox.get_extra_series(msgs, direction=1, nocache=True) if wantver > 1: msgs = b4.mbox.get_extra_series( - msgs, direction=-1, - wantvers=[wantver], nocache=True) - lser = b4.review._get_lore_series( - msgs, wantver=wantver) + msgs, direction=-1, wantvers=[wantver], nocache=True + ) + lser = b4.review._get_lore_series(msgs, wantver=wantver) else: raise am_msgs = lser.get_am_ready( - noaddtrailers=True, addmysob=False, addlink=False, - cherrypick=None, copyccs=False, allowbadchars=False, - showchecks=False) + noaddtrailers=True, + addmysob=False, + addlink=False, + cherrypick=None, + copyccs=False, + allowbadchars=False, + showchecks=False, + ) if not am_msgs: raise LookupError('No patches ready for applying') @@ -1312,25 +1425,27 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: guessed, nblobs, mismatches = lser.find_base( topdir, - branches=['--exclude=refs/heads/b4/review/*', - '--all'], - maxdays=30) + branches=['--exclude=refs/heads/b4/review/*', '--all'], + maxdays=30, + ) if guessed: # find_base returns a describe name (e.g. heads/foo); # resolve it to a SHA for the input field ecode, sha_out = b4.git_run_command( - topdir, ['rev-parse', '--verify', guessed]) + topdir, ['rev-parse', '--verify', guessed] + ) sha = sha_out.strip() if ecode == 0 else '' short_sha = sha[:12] if sha else guessed if mismatches == 0: initial_base = short_sha - base_hint = (f'Guessed base: {guessed}' - f' (exact match)') + base_hint = f'Guessed base: {guessed} (exact match)' elif nblobs != mismatches: matched = nblobs - mismatches initial_base = short_sha - base_hint = (f'Guessed base: {guessed}' - f' ({matched}/{nblobs} blobs)') + base_hint = ( + f'Guessed base: {guessed}' + f' ({matched}/{nblobs} blobs)' + ) else: base_hint = 'Could not find a matching base' except (IndexError, Exception): @@ -1343,7 +1458,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._identifier, series.get('change_id', ''), series.get('revision', 1), - att) + att, + ) return lser, ambytes, initial_base, base_hint @@ -1352,8 +1468,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): callback=lambda result: self._on_series_fetched(result, series), ) - def _on_series_fetched(self, result: Any, - series: Dict[str, Any]) -> None: + def _on_series_fetched(self, result: Any, series: Dict[str, Any]) -> None: """Handle the result from the series fetch worker.""" if result is None: return @@ -1376,28 +1491,35 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): base_suggestions.append(rb) self.push_screen( - BaseSelectionScreen(initial_base, lser, ambytes, - base_suggestions=base_suggestions, - base_hint=base_hint, - subject=series.get('subject', '')), + BaseSelectionScreen( + initial_base, + lser, + ambytes, + base_suggestions=base_suggestions, + base_hint=base_hint, + subject=series.get('subject', ''), + ), callback=lambda base_sha: self._on_base_selected( - base_sha, lser, series, ambytes), + base_sha, lser, series, ambytes + ), ) - def _on_base_selected(self, base_sha: Optional[str], - lser: b4.LoreSeries, - series: Dict[str, Any], - ambytes: bytes) -> None: + def _on_base_selected( + self, + base_sha: Optional[str], + lser: b4.LoreSeries, + series: Dict[str, Any], + ambytes: bytes, + ) -> None: """Handle base selection screen result.""" if base_sha is None: self.notify('Checkout cancelled', severity='information') return - self._do_checkout(lser, series, base_commit=base_sha, - ambytes=ambytes) + self._do_checkout(lser, series, base_commit=base_sha, ambytes=ambytes) - def _discover_newer_versions(self, change_id: str, - current_rev: int, - review_branch: str) -> List[int]: + def _discover_newer_versions( + self, change_id: str, current_rev: int, review_branch: str + ) -> List[int]: """Look up newer revision numbers from tracking data and DB.""" newer_versions: List[int] = [] topdir = b4.git_get_toplevel() @@ -1419,8 +1541,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return newer_versions @staticmethod - def _resolve_base_commit(topdir: str, - lser: 'b4.LoreSeries') -> Optional[str]: + def _resolve_base_commit(topdir: str, lser: 'b4.LoreSeries') -> Optional[str]: """Determine the base commit for a series, guessing if needed. Returns the base commit SHA or None if it cannot be determined. @@ -1429,8 +1550,10 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): need_guess = False if base_commit: if not b4.git_commit_exists(topdir, base_commit): - logger.warning('Base commit %s not found in repository, will try to guess', - base_commit) + logger.warning( + 'Base commit %s not found in repository, will try to guess', + base_commit, + ) need_guess = True else: need_guess = True @@ -1439,23 +1562,33 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): logger.info('Guessing base commit...') try: base_commit, nblobs, mismatches = lser.find_base( - topdir, branches=None, maxdays=30) + topdir, branches=None, maxdays=30 + ) if mismatches == 0: logger.info('Base: %s (exact match)', base_commit) elif nblobs == mismatches: logger.warning('Base: failed to find matching base') base_commit = None else: - logger.info('Base: %s (best guess, %s/%s blobs matched)', - base_commit, nblobs - mismatches, nblobs) + logger.info( + 'Base: %s (best guess, %s/%s blobs matched)', + base_commit, + nblobs - mismatches, + nblobs, + ) except IndexError as ex: logger.warning('Base: failed to guess (%s)', ex) base_commit = None return base_commit - def _do_checkout(self, lser: b4.LoreSeries, series: Dict[str, Any], - base_commit: str, ambytes: bytes) -> None: + def _do_checkout( + self, + lser: b4.LoreSeries, + series: Dict[str, Any], + base_commit: str, + ambytes: bytes, + ) -> None: """Create the review branch for the series. Args: @@ -1505,20 +1638,36 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if mismatches: rstart, rend = lser.make_fake_am_range(gitdir=topdir) if rstart and rend: - logger.info('Prepared fake commit range for 3-way merge (%.12s..%.12s)', rstart, rend) + logger.info( + 'Prepared fake commit range for 3-way merge (%.12s..%.12s)', + rstart, + rend, + ) try: logger.info('Base: %s', base_commit) - b4.git_fetch_am_into_repo(topdir, ambytes=ambytes, at_base=base_commit, - origin=linkurl, am_flags=['-3']) + b4.git_fetch_am_into_repo( + topdir, + ambytes=ambytes, + at_base=base_commit, + origin=linkurl, + am_flags=['-3'], + ) # Create the review branch _is_rt = bool(series.get('is_rethreaded')) - b4.review.create_review_branch(topdir, branch_name, base_commit, lser, - linkurl, linkmask, num_prereqs=0, - identifier=self._identifier, - status='reviewing', - is_rethreaded=_is_rt) + b4.review.create_review_branch( + topdir, + branch_name, + base_commit, + lser, + linkurl, + linkmask, + num_prereqs=0, + identifier=self._identifier, + status='reviewing', + is_rethreaded=_is_rt, + ) logger.info('Review branch created: %s', branch_name) checkout_success = True except b4.AmConflictError as cex: @@ -1527,11 +1676,18 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return b4._rewrite_fetch_head_origin(topdir, cex.worktree_path, linkurl) # Create the review branch from resolved result - b4.review.create_review_branch(topdir, branch_name, base_commit, lser, - linkurl, linkmask, num_prereqs=0, - identifier=self._identifier, - status='reviewing', - is_rethreaded=_is_rt) + b4.review.create_review_branch( + topdir, + branch_name, + base_commit, + lser, + linkurl, + linkmask, + num_prereqs=0, + identifier=self._identifier, + status='reviewing', + is_rethreaded=_is_rt, + ) logger.info('Review branch created: %s', branch_name) checkout_success = True except Exception as ex: @@ -1545,9 +1701,15 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if self._identifier: try: conn = b4.review.tracking.get_db(self._identifier) - conn.execute('UPDATE series SET status = ?, revision = ?, message_id = ? WHERE track_id = ?', - ('reviewing', series.get('revision'), series.get('message_id'), - series.get('track_id'))) + conn.execute( + 'UPDATE series SET status = ?, revision = ?, message_id = ? WHERE track_id = ?', + ( + 'reviewing', + series.get('revision'), + series.get('message_id'), + series.get('track_id'), + ), + ) conn.commit() conn.close() except Exception as ex: @@ -1559,7 +1721,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: conn = b4.review.tracking.get_db(self._identifier) db_target = b4.review.tracking.get_target_branch( - conn, _co_change_id, revision=series.get('revision')) + conn, _co_change_id, revision=series.get('revision') + ) conn.close() if db_target and b4.git_branch_exists(topdir, branch_name): cover_text, tracking = b4.review.load_tracking(topdir, branch_name) @@ -1567,7 +1730,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if not trk_series.get('target-branch'): trk_series['target-branch'] = db_target tracking['series'] = trk_series - b4.review.save_tracking_ref(topdir, branch_name, cover_text, tracking) + b4.review.save_tracking_ref( + topdir, branch_name, cover_text, tracking + ) except (SystemExit, Exception): pass @@ -1582,7 +1747,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if self._identifier and _co_change_id and isinstance(_co_revision, int): try: conn = b4.review.tracking.get_db(self._identifier) - b4.review.tracking.mark_all_messages_seen(conn, _co_change_id, _co_revision) + b4.review.tracking.mark_all_messages_seen( + conn, _co_change_id, _co_revision + ) conn.close() except Exception: pass @@ -1709,7 +1876,10 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Show target branch from preloaded series data or config default target_row = self.query_one('#detail-target-row', Horizontal) - target_branch = series.get('target_branch') or b4.review.tracking.get_review_target_branch_default() + target_branch = ( + series.get('target_branch') + or b4.review.tracking.get_review_target_branch_default() + ) if target_branch: self.query_one('#detail-target', Static).update(target_branch) target_row.display = True @@ -1744,7 +1914,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: conn = b4.review.tracking.get_db(self._identifier) db_target = b4.review.tracking.get_target_branch( - conn, change_id, revision=series.get('revision')) + conn, change_id, revision=series.get('revision') + ) conn.close() if db_target: return db_target @@ -1785,11 +1956,14 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): review_branch = rb self.push_screen( - TargetBranchScreen(current_target, suggestions=suggestions or None, - subject=series.get('subject', ''), - message_id=series.get('message_id', ''), - revision=series.get('revision'), - review_branch=review_branch), + TargetBranchScreen( + current_target, + suggestions=suggestions or None, + subject=series.get('subject', ''), + message_id=series.get('message_id', ''), + revision=series.get('revision'), + review_branch=review_branch, + ), callback=self._on_target_branch_set, ) @@ -1813,22 +1987,27 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if topdir and status in ('reviewing', 'replied', 'waiting', 'snoozed'): if b4.git_branch_exists(topdir, review_branch): try: - cover_text, tracking = b4.review.load_tracking(topdir, review_branch) + cover_text, tracking = b4.review.load_tracking( + topdir, review_branch + ) trk_series = tracking.get('series', {}) if target_value: trk_series['target-branch'] = target_value else: trk_series.pop('target-branch', None) tracking['series'] = trk_series - b4.review.save_tracking_ref(topdir, review_branch, cover_text, tracking) + b4.review.save_tracking_ref( + topdir, review_branch, cover_text, tracking + ) except (SystemExit, Exception) as ex: logger.warning('Could not update tracking commit: %s', ex) # Update database try: conn = b4.review.tracking.get_db(self._identifier) - b4.review.tracking.update_target_branch(conn, change_id, target_value, - revision=revision) + b4.review.tracking.update_target_branch( + conn, change_id, target_value, revision=revision + ) conn.close() except Exception as ex: logger.warning('Could not update target branch in DB: %s', ex) @@ -1853,8 +2032,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._focus_change_id = self._selected_series.get('change_id') self.push_screen( - UpdateAllScreen([self._selected_series], self._identifier, - linkmask, topdir), + UpdateAllScreen( + [self._selected_series], self._identifier, linkmask, topdir + ), callback=self._on_update_complete, ) @@ -1900,7 +2080,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if errors: parts.append(f'{errors} error(s)') - severity: Literal['information', 'warning'] = 'warning' if errors else 'information' + severity: Literal['information', 'warning'] = ( + 'warning' if errors else 'information' + ) self.notify(', '.join(parts), severity=severity) for submitter, error in error_details: logger.warning('Update error (%s): %s', submitter, error) @@ -1947,42 +2129,58 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Check if a newer revision is known to exist current_rev = series.get('revision', 1) newer_versions = self._discover_newer_versions( - change_id, current_rev, review_branch) + change_id, current_rev, review_branch + ) if newer_versions: # Require explicit confirmation before taking an older revision self.push_screen( NewerRevisionWarningScreen(current_rev, newer_versions), callback=lambda proceed: self._on_newer_revision_acknowledged( - proceed, target_branch, change_id, review_branch, series), + proceed, target_branch, change_id, review_branch, series + ), ) else: self._show_take_screen(target_branch, change_id, review_branch, series) - def _on_newer_revision_acknowledged(self, proceed: bool, target_branch: str, - change_id: str, review_branch: str, - series: Dict[str, Any]) -> None: + def _on_newer_revision_acknowledged( + self, + proceed: bool, + target_branch: str, + change_id: str, + review_branch: str, + series: Dict[str, Any], + ) -> None: """Handle result of the newer-revision warning.""" if not proceed: return self._show_take_screen(target_branch, change_id, review_branch, series) - def _show_take_screen(self, target_branch: str, change_id: str, - review_branch: str, series: Dict[str, Any]) -> None: + def _show_take_screen( + self, + target_branch: str, + change_id: str, + review_branch: str, + series: Dict[str, Any], + ) -> None: """Push the TakeScreen dialog.""" num_patches = series.get('num_patches', 0) or 0 # Start with user config preference; skip detection below may override it. _valid_take_methods = {'merge', 'linear', 'cherry-pick'} b4cfg = b4.get_config_from_git(r'b4\..*') cfg_method = str(b4cfg.get('review-default-take-method', '')) - default_method: Optional[str] = cfg_method if cfg_method in _valid_take_methods else None + default_method: Optional[str] = ( + cfg_method if cfg_method in _valid_take_methods else None + ) topdir = b4.git_get_toplevel() if topdir: try: _cover_text, tracking = b4.review.load_tracking(topdir, review_branch) usercfg = b4.get_user_config() patches = tracking.get('patches', []) - if any(b4.review._get_patch_state(p, usercfg) == 'skip' for p in patches): + if any( + b4.review._get_patch_state(p, usercfg) == 'skip' for p in patches + ): default_method = 'cherry-pick' except Exception: pass @@ -1995,7 +2193,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if recent_branches and not per_series_target: target_branch = recent_branches[0] # Build the suggestion list: config branches + recent take branches - all_suggestions: List[str] = list(b4.review.tracking.get_review_target_branches()) + all_suggestions: List[str] = list( + b4.review.tracking.get_review_target_branches() + ) if recent_branches: for rb in recent_branches: if rb not in all_suggestions: @@ -2003,19 +2203,29 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if target_branch and target_branch not in all_suggestions: all_suggestions.append(target_branch) recent_branches = all_suggestions or None - take_screen = TakeScreen(target_branch, review_branch, num_patches=num_patches, - default_method=default_method, - recent_branches=recent_branches, - subject=series.get('subject', '')) + take_screen = TakeScreen( + target_branch, + review_branch, + num_patches=num_patches, + default_method=default_method, + recent_branches=recent_branches, + subject=series.get('subject', ''), + ) self.push_screen( take_screen, callback=lambda confirmed: self._on_take_confirmed( - confirmed, change_id, review_branch, take_screen, series), + confirmed, change_id, review_branch, take_screen, series + ), ) - def _on_take_confirmed(self, confirmed: bool, change_id: str, - review_branch: str, take_screen: 'TakeScreen', - series: Dict[str, Any]) -> None: + def _on_take_confirmed( + self, + confirmed: bool, + change_id: str, + review_branch: str, + take_screen: 'TakeScreen', + series: Dict[str, Any], + ) -> None: """Handle take screen result — proceed to cherry-pick or confirm.""" if not confirmed: return @@ -2036,7 +2246,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return usercfg = b4.get_user_config() preselected = [ - i + 1 for i, p in enumerate(patches) + i + 1 + for i, p in enumerate(patches) if b4.review._get_patch_state(p, usercfg) != 'skip' ] # Only pre-populate if some patches are actually skipped @@ -2046,49 +2257,81 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self.push_screen( pick_screen, callback=lambda picked: self._on_cherrypick_confirmed( - picked, change_id, review_branch, take_screen, series, - pick_screen), + picked, change_id, review_branch, take_screen, series, pick_screen + ), ) else: self._show_take_confirm( - take_screen.method_result, take_screen.target_result, - change_id, review_branch, take_screen, series) + take_screen.method_result, + take_screen.target_result, + change_id, + review_branch, + take_screen, + series, + ) - def _on_cherrypick_confirmed(self, confirmed: bool, change_id: str, - review_branch: str, take_screen: 'TakeScreen', - series: Dict[str, Any], - pick_screen: 'CherryPickScreen') -> None: + def _on_cherrypick_confirmed( + self, + confirmed: bool, + change_id: str, + review_branch: str, + take_screen: 'TakeScreen', + series: Dict[str, Any], + pick_screen: 'CherryPickScreen', + ) -> None: """Handle cherry-pick selection — proceed to confirm screen.""" if not confirmed: return self._show_take_confirm( - 'cherry-pick', take_screen.target_result, - change_id, review_branch, take_screen, series, - cherrypick=pick_screen.selected_indices) - - def _show_take_confirm(self, method: str, target_branch: str, - change_id: str, review_branch: str, - take_screen: 'TakeScreen', - series: Dict[str, Any], - cherrypick: Optional[List[int]] = None) -> None: + 'cherry-pick', + take_screen.target_result, + change_id, + review_branch, + take_screen, + series, + cherrypick=pick_screen.selected_indices, + ) + + def _show_take_confirm( + self, + method: str, + target_branch: str, + change_id: str, + review_branch: str, + take_screen: 'TakeScreen', + series: Dict[str, Any], + cherrypick: Optional[List[int]] = None, + ) -> None: """Push the TakeConfirmScreen for final confirmation.""" subject = series.get('subject', '') confirm_screen = TakeConfirmScreen( - method, target_branch, review_branch, subject=subject, - cherrypick=cherrypick) + method, target_branch, review_branch, subject=subject, cherrypick=cherrypick + ) self.push_screen( confirm_screen, callback=lambda ok: self._on_take_final( - ok, method, change_id, review_branch, take_screen, - series, confirm_screen, cherrypick), + ok, + method, + change_id, + review_branch, + take_screen, + series, + confirm_screen, + cherrypick, + ), ) - def _on_take_final(self, confirmed: bool, method: str, - change_id: str, review_branch: str, - take_screen: 'TakeScreen', - series: Dict[str, Any], - confirm_screen: 'TakeConfirmScreen', - cherrypick: Optional[List[int]] = None) -> None: + def _on_take_final( + self, + confirmed: bool, + method: str, + change_id: str, + review_branch: str, + take_screen: 'TakeScreen', + series: Dict[str, Any], + confirm_screen: 'TakeConfirmScreen', + cherrypick: Optional[List[int]] = None, + ) -> None: """Execute the actual take after final confirmation.""" if not confirmed: return @@ -2101,15 +2344,20 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._load_series() else: with self.suspend(): - self._do_take_am(change_id, review_branch, take_screen, series, - cherrypick=cherrypick) + self._do_take_am( + change_id, review_branch, take_screen, series, cherrypick=cherrypick + ) self._load_series() @staticmethod - def _record_take_metadata(topdir: str, review_branch: str, - target_branch: str, commit_ids: List[str], - cherrypick: Optional[List[int]] = None, - accepted: bool = True) -> None: + def _record_take_metadata( + topdir: str, + review_branch: str, + target_branch: str, + commit_ids: List[str], + cherrypick: Optional[List[int]] = None, + accepted: bool = True, + ) -> None: """Record taken commit IDs in the tracking data on the review branch. Args: @@ -2166,9 +2414,13 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if not b4.review.save_tracking_ref(topdir, review_branch, cover_text, tracking): logger.warning('Could not save take metadata to tracking commit') - def _do_take_merge(self, change_id: str, review_branch: str, - take_screen: 'TakeScreen', - series: Dict[str, Any]) -> None: + def _do_take_merge( + self, + change_id: str, + review_branch: str, + take_screen: 'TakeScreen', + series: Dict[str, Any], + ) -> None: """Perform a merge-based take operation.""" target_branch = take_screen.target_result @@ -2199,15 +2451,19 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: merge_template = b4.read_template(str(config['shazam-merge-template'])) except FileNotFoundError: - logger.critical('ERROR: shazam-merge-template says to use %s, but it does not exist', - config['shazam-merge-template']) + logger.critical( + 'ERROR: shazam-merge-template says to use %s, but it does not exist', + config['shazam-merge-template'], + ) _wait_for_enter() return # Extract cover message body covermessage = '' if cover_text: - _githeaders, message, _trailers, _basement, _sig = b4.LoreMessage.get_body_parts(cover_text) + _githeaders, message, _trailers, _basement, _sig = ( + b4.LoreMessage.get_body_parts(cover_text) + ) covermessage = message.strip() if not covermessage: covermessage = '(no cover letter message)' @@ -2277,7 +2533,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): base_commit = out.strip() try: - b4.git_fetch_am_into_repo(topdir, ambytes, at_base=base_commit, am_flags=['-3']) + b4.git_fetch_am_into_repo( + topdir, ambytes, at_base=base_commit, am_flags=['-3'] + ) except b4.AmConflictError as cex: if not _resolve_worktree_am_conflict(topdir, cex): _wait_for_enter() @@ -2292,7 +2550,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): prev_branch = b4.git_revparse_obj('HEAD', gitdir=topdir) # Checkout target branch - ecode, out = b4.git_run_command(topdir, ['checkout', target_branch], logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['checkout', target_branch], logstderr=True + ) if ecode != 0: logger.critical('Could not checkout %s: %s', target_branch, out.strip()) _wait_for_enter() @@ -2334,20 +2594,31 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # After --no-ff merge, HEAD^2 is the tip of the merged side; # the individual patch commits are base_commit..HEAD^2. ecode, out = b4.git_run_command( - topdir, ['rev-list', '--reverse', f'{base_commit}..HEAD^2']) + topdir, ['rev-list', '--reverse', f'{base_commit}..HEAD^2'] + ) if ecode == 0 and out.strip(): commit_ids = out.strip().splitlines() - self._record_take_metadata(topdir, review_branch, target_branch, - commit_ids, - accepted=take_screen.accept_series) + self._record_take_metadata( + topdir, + review_branch, + target_branch, + commit_ids, + accepted=take_screen.accept_series, + ) - self._finalize_take(topdir, target_branch, change_id, t_series, - take_screen.accept_series) + self._finalize_take( + topdir, target_branch, change_id, t_series, take_screen.accept_series + ) _wait_for_enter() - def _finalize_take(self, topdir: str, target_branch: str, - change_id: str, series: Dict[str, Any], - accepted: bool) -> None: + def _finalize_take( + self, + topdir: str, + target_branch: str, + change_id: str, + series: Dict[str, Any], + accepted: bool, + ) -> None: """Common post-take steps: record branch, update DB, update Patchwork.""" common_dir = b4.git_get_common_dir(topdir) if common_dir: @@ -2358,14 +2629,17 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): existing_target = None try: conn = b4.review.tracking.get_db(self._identifier) - b4.review.tracking.update_series_status(conn, change_id, 'accepted', - revision=revision) + b4.review.tracking.update_series_status( + conn, change_id, 'accepted', revision=revision + ) # Record the take target as the series target branch if not already set existing_target = b4.review.tracking.get_target_branch( - conn, change_id, revision=revision) + conn, change_id, revision=revision + ) if not existing_target: b4.review.tracking.update_target_branch( - conn, change_id, target_branch, revision=revision) + conn, change_id, target_branch, revision=revision + ) conn.close() except Exception as ex: logger.warning('Could not update series status: %s', ex) @@ -2373,13 +2647,16 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): review_branch = f'b4/review/{change_id}' if not existing_target and b4.git_branch_exists(topdir, review_branch): try: - cover_text, tracking = b4.review.load_tracking(topdir, review_branch) + cover_text, tracking = b4.review.load_tracking( + topdir, review_branch + ) trk_series = tracking.get('series', {}) if not trk_series.get('target-branch'): trk_series['target-branch'] = target_branch tracking['series'] = trk_series - b4.review.save_tracking_ref(topdir, review_branch, - cover_text, tracking) + b4.review.save_tracking_ref( + topdir, review_branch, cover_text, tracking + ) except (SystemExit, Exception): pass @@ -2435,8 +2712,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Generate patches from local commits revision = t_series.get('revision', 1) try: - local_patches = b4.git_range_to_patches(topdir, range_start, range_end, - revision=revision) + local_patches = b4.git_range_to_patches( + topdir, range_start, range_end, revision=revision + ) except RuntimeError as ex: logger.critical('Could not generate patches: %s', ex) _wait_for_enter() @@ -2460,8 +2738,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): for _commit, msg in local_patches: lmbx.add_message(msg) - lser = lmbx.get_series(revision, sloppytrailers=False, - codereview_trailers=False) + lser = lmbx.get_series( + revision, sloppytrailers=False, codereview_trailers=False + ) if lser is None: logger.critical('Could not build series from local patches') _wait_for_enter() @@ -2497,20 +2776,25 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): patch.followup_trailers.append(fltr) # Get am-ready messages - am_msgs = lser.get_am_ready(noaddtrailers=False, - addmysob=take_screen.add_signoff, - addlink=take_screen.add_link, - cherrypick=cherrypick, - copyccs=False, - allowbadchars=False) + am_msgs = lser.get_am_ready( + noaddtrailers=False, + addmysob=take_screen.add_signoff, + addlink=take_screen.add_link, + cherrypick=cherrypick, + copyccs=False, + allowbadchars=False, + ) if not am_msgs: logger.critical('No patches ready for applying') _wait_for_enter() return None if cherrypick: - logger.info('Prepared %d patch(es) (cherry-picked: %s)', - len(am_msgs), ', '.join(str(x) for x in cherrypick)) + logger.info( + 'Prepared %d patch(es) (cherry-picked: %s)', + len(am_msgs), + ', '.join(str(x) for x in cherrypick), + ) else: logger.info('Prepared %d patch(es)', len(am_msgs)) @@ -2519,9 +2803,14 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): b4.save_git_am_mbox(am_msgs, ifh) return ifh.getvalue() - def _do_take_am(self, change_id: str, review_branch: str, - take_screen: 'TakeScreen', series: Dict[str, Any], - cherrypick: Optional[List[int]]) -> None: + def _do_take_am( + self, + change_id: str, + review_branch: str, + take_screen: 'TakeScreen', + series: Dict[str, Any], + cherrypick: Optional[List[int]], + ) -> None: """Perform a linear or cherry-pick take via git-am.""" target_branch = take_screen.target_result @@ -2530,8 +2819,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): logger.critical('Not in a git repository') return - ambytes = self._prepare_am_messages(review_branch, take_screen, series, - cherrypick=cherrypick) + ambytes = self._prepare_am_messages( + review_branch, take_screen, series, cherrypick=cherrypick + ) if ambytes is None: return @@ -2541,7 +2831,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): prev_branch = b4.git_revparse_obj('HEAD', gitdir=topdir) # Checkout target branch - ecode, out = b4.git_run_command(topdir, ['checkout', target_branch], logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['checkout', target_branch], logstderr=True + ) if ecode != 0: logger.critical('Could not checkout %s: %s', target_branch, out.strip()) _wait_for_enter() @@ -2552,12 +2844,16 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): pre_am_head = out.strip() if ecode == 0 else '' # Run git-am with three-way merge - ecode, out = b4.git_run_command(topdir, ['am', '-3'], stdin=ambytes, logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['am', '-3'], stdin=ambytes, logstderr=True + ) if ecode != 0: logger.critical('git-am failed:') logger.critical(out.strip()) logger.info('You can resolve the conflict now.') - logger.info('Use "git am --continue" after resolving, or "git am --abort" to give up.') + logger.info( + 'Use "git am --continue" after resolving, or "git am --abort" to give up.' + ) _suspend_to_shell(hint='b4 conflict') # Check if am is still in progress (user exited without finishing) rebase_apply_path = os.path.join(topdir, '.git', 'rebase-apply') @@ -2567,7 +2863,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): _wait_for_enter() return # Check if am was aborted (HEAD unchanged) - ecode, current_head = b4.git_run_command(topdir, ['rev-parse', 'HEAD'], logstderr=True) + ecode, current_head = b4.git_run_command( + topdir, ['rev-parse', 'HEAD'], logstderr=True + ) if ecode != 0 or current_head.strip() == pre_am_head: logger.warning('Conflict resolution aborted') _wait_for_enter() @@ -2580,15 +2878,22 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Record per-patch commit IDs in the tracking data if pre_am_head: ecode, out = b4.git_run_command( - topdir, ['rev-list', '--reverse', f'{pre_am_head}..HEAD']) + topdir, ['rev-list', '--reverse', f'{pre_am_head}..HEAD'] + ) if ecode == 0: commit_ids = out.strip().splitlines() - self._record_take_metadata(topdir, review_branch, target_branch, - commit_ids, cherrypick=cherrypick, - accepted=take_screen.accept_series) + self._record_take_metadata( + topdir, + review_branch, + target_branch, + commit_ids, + cherrypick=cherrypick, + accepted=take_screen.accept_series, + ) - self._finalize_take(topdir, target_branch, change_id, series, - take_screen.accept_series) + self._finalize_take( + topdir, target_branch, change_id, series, take_screen.accept_series + ) _wait_for_enter() def action_rebase(self) -> None: @@ -2597,7 +2902,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return status = self._selected_series.get('status', 'new') if status not in ('reviewing', 'replied'): - self.notify('Series must be checked out before rebasing', severity='warning') + self.notify( + 'Series must be checked out before rebasing', severity='warning' + ) return series = self._selected_series @@ -2619,22 +2926,31 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): elif recent_branches: current_branch = recent_branches[0] # Ensure the original branch is always in the suggestion list - if current_branch and recent_branches is not None and current_branch not in recent_branches: + if ( + current_branch + and recent_branches is not None + and current_branch not in recent_branches + ): recent_branches.append(current_branch) elif current_branch and recent_branches is None: recent_branches = [current_branch] - rebase_screen = RebaseScreen(current_branch, review_branch, - recent_branches=recent_branches, - subject=self._selected_series.get('subject', '')) + rebase_screen = RebaseScreen( + current_branch, + review_branch, + recent_branches=recent_branches, + subject=self._selected_series.get('subject', ''), + ) self.push_screen( rebase_screen, callback=lambda confirmed: self._on_rebase_confirmed( - confirmed, review_branch, rebase_screen), + confirmed, review_branch, rebase_screen + ), ) - def _on_rebase_confirmed(self, confirmed: bool, review_branch: str, - rebase_screen: 'RebaseScreen') -> None: + def _on_rebase_confirmed( + self, confirmed: bool, review_branch: str, rebase_screen: 'RebaseScreen' + ) -> None: """Handle rebase confirmation result.""" if not confirmed: return @@ -2691,12 +3007,16 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: with b4.git_temp_worktree(topdir, target_head) as gwt: # Set up sparse checkout for minimal disk usage - ecode, out = b4.git_run_command(gwt, ['sparse-checkout', 'set'], logstderr=True) + ecode, out = b4.git_run_command( + gwt, ['sparse-checkout', 'set'], logstderr=True + ) if ecode != 0: logger.warning('Could not set up sparse checkout: %s', out.strip()) ecode, out = b4.git_run_command(gwt, ['checkout', '-f'], logstderr=True) if ecode != 0: - logger.warning('Could not checkout sparse worktree: %s', out.strip()) + logger.warning( + 'Could not checkout sparse worktree: %s', out.strip() + ) # Try cherry-picking the commits gitargs = ['cherry-pick', f'{base_commit}..{series_tip}'] @@ -2716,8 +3036,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): logger.info('Rebasing %s onto %s...', review_branch, target_branch) # Remember where we are so we can restore on failure - ecode, original_branch = b4.git_run_command(topdir, ['rev-parse', '--abbrev-ref', 'HEAD'], - logstderr=True) + ecode, original_branch = b4.git_run_command( + topdir, ['rev-parse', '--abbrev-ref', 'HEAD'], logstderr=True + ) if ecode != 0: logger.critical('Could not determine current branch') _wait_for_enter() @@ -2725,14 +3046,18 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): original_branch = original_branch.strip() # First, checkout the review branch (at the tracking commit) - ecode, out = b4.git_run_command(topdir, ['checkout', review_branch], logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['checkout', review_branch], logstderr=True + ) if ecode != 0: logger.critical('Could not checkout review branch: %s', out.strip()) _wait_for_enter() return # Save the tracking commit SHA so we can restore on failure - ecode, tracking_commit = b4.git_run_command(topdir, ['rev-parse', 'HEAD'], logstderr=True) + ecode, tracking_commit = b4.git_run_command( + topdir, ['rev-parse', 'HEAD'], logstderr=True + ) if ecode != 0: logger.critical('Could not resolve tracking commit') b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True) @@ -2741,25 +3066,37 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): tracking_commit = tracking_commit.strip() # Reset to before the tracking commit (now at series_tip) - ecode, out = b4.git_run_command(topdir, ['reset', '--hard', 'HEAD~1'], logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['reset', '--hard', 'HEAD~1'], logstderr=True + ) if ecode != 0: - logger.critical('Could not reset to before tracking commit: %s', out.strip()) - b4.git_run_command(topdir, ['reset', '--hard', tracking_commit], logstderr=True) + logger.critical( + 'Could not reset to before tracking commit: %s', out.strip() + ) + b4.git_run_command( + topdir, ['reset', '--hard', tracking_commit], logstderr=True + ) b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True) _wait_for_enter() return # Rebase the patches onto target_head # --onto target_head base_commit means: take commits after base_commit and replay onto target_head - ecode, out = b4.git_run_command(topdir, ['rebase', '--onto', target_head, base_commit], logstderr=True) + ecode, out = b4.git_run_command( + topdir, ['rebase', '--onto', target_head, base_commit], logstderr=True + ) if ecode != 0: if applies_clean: # Test said clean but real rebase failed — something is wrong, abort logger.critical('Rebase failed unexpectedly: %s', out.strip()) logger.critical('Aborting rebase...') b4.git_run_command(topdir, ['rebase', '--abort'], logstderr=True) - b4.git_run_command(topdir, ['reset', '--hard', tracking_commit], logstderr=True) - b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True) + b4.git_run_command( + topdir, ['reset', '--hard', tracking_commit], logstderr=True + ) + b4.git_run_command( + topdir, ['checkout', original_branch], logstderr=True + ) _wait_for_enter() return @@ -2768,37 +3105,62 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): logger.critical('---') logger.critical('Rebase had conflicts.') logger.info('You can resolve the conflicts in your working tree.') - logger.info('Use "git rebase --continue" after resolving, or "git rebase --abort" to give up.') + logger.info( + 'Use "git rebase --continue" after resolving, or "git rebase --abort" to give up.' + ) _suspend_to_shell(hint='b4 rebase') # Check if rebase is still in progress (user exited without finishing) - ecode, gitdir = b4.git_run_command(topdir, ['rev-parse', '--git-dir'], logstderr=True) + ecode, gitdir = b4.git_run_command( + topdir, ['rev-parse', '--git-dir'], logstderr=True + ) rebase_in_progress = False if ecode == 0: gitdir = gitdir.strip() - rebase_in_progress = (os.path.isdir(os.path.join(gitdir, 'rebase-merge')) - or os.path.isdir(os.path.join(gitdir, 'rebase-apply'))) + rebase_in_progress = os.path.isdir( + os.path.join(gitdir, 'rebase-merge') + ) or os.path.isdir(os.path.join(gitdir, 'rebase-apply')) if rebase_in_progress: logger.warning('Rebase not completed, aborting') b4.git_run_command(topdir, ['rebase', '--abort'], logstderr=True) - b4.git_run_command(topdir, ['reset', '--hard', tracking_commit], logstderr=True) - b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True) + b4.git_run_command( + topdir, ['reset', '--hard', tracking_commit], logstderr=True + ) + b4.git_run_command( + topdir, ['checkout', original_branch], logstderr=True + ) _wait_for_enter() return # Check if the rebase was aborted (HEAD back at pre-rebase state) - ecode, current_head = b4.git_run_command(topdir, ['rev-parse', 'HEAD'], logstderr=True) + ecode, current_head = b4.git_run_command( + topdir, ['rev-parse', 'HEAD'], logstderr=True + ) if ecode != 0 or current_head.strip() == series_tip: logger.warning('Rebase was aborted') - b4.git_run_command(topdir, ['reset', '--hard', tracking_commit], logstderr=True) - b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True) + b4.git_run_command( + topdir, ['reset', '--hard', tracking_commit], logstderr=True + ) + b4.git_run_command( + topdir, ['checkout', original_branch], logstderr=True + ) _wait_for_enter() return # Verify target is an ancestor of HEAD (rebase actually landed) ecode, _out = b4.git_run_command( - topdir, ['merge-base', '--is-ancestor', target_head, 'HEAD'], logstderr=True) + topdir, + ['merge-base', '--is-ancestor', target_head, 'HEAD'], + logstderr=True, + ) if ecode != 0: - logger.warning('Rebase result does not include %s, something went wrong', target_branch) - b4.git_run_command(topdir, ['reset', '--hard', tracking_commit], logstderr=True) - b4.git_run_command(topdir, ['checkout', original_branch], logstderr=True) + logger.warning( + 'Rebase result does not include %s, something went wrong', + target_branch, + ) + b4.git_run_command( + topdir, ['reset', '--hard', tracking_commit], logstderr=True + ) + b4.git_run_command( + topdir, ['checkout', original_branch], logstderr=True + ) _wait_for_enter() return logger.info('Rebase conflicts resolved') @@ -2808,7 +3170,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Enumerate new patch commit SHAs and update first-patch-commit ecode, out = b4.git_run_command( - topdir, ['rev-list', '--reverse', f'{target_head}..HEAD']) + topdir, ['rev-list', '--reverse', f'{target_head}..HEAD'] + ) if ecode == 0 and out.strip(): new_shas = out.strip().splitlines() series['first-patch-commit'] = new_shas[0] @@ -2820,8 +3183,12 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Re-create the tracking commit commit_msg = cover_text + '\n\n' + b4.review.make_review_magic_json(tracking) - ecode, out = b4.git_run_command(topdir, ['commit', '--allow-empty', '-F', '-'], - stdin=commit_msg.encode(), logstderr=True) + ecode, out = b4.git_run_command( + topdir, + ['commit', '--allow-empty', '-F', '-'], + stdin=commit_msg.encode(), + logstderr=True, + ) if ecode != 0: logger.critical('Could not create new tracking commit: %s', out.strip()) _wait_for_enter() @@ -2863,11 +3230,14 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self.push_screen( RangeDiffScreen(current_rev, revisions), - callback=lambda chosen: self._on_range_diff_selected(chosen, change_id, current_rev), + callback=lambda chosen: self._on_range_diff_selected( + chosen, change_id, current_rev + ), ) - def _on_range_diff_selected(self, chosen: Optional[int], change_id: str, - current_rev: int) -> None: + def _on_range_diff_selected( + self, chosen: Optional[int], change_id: str, current_rev: int + ) -> None: """Handle the revision chosen from the range-diff modal.""" if chosen is None: return @@ -2876,7 +3246,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): @staticmethod def _fetch_fake_am_range( - topdir: str, revisions: List[Dict[str, Any]], rev: int, + topdir: str, + revisions: List[Dict[str, Any]], + rev: int, ) -> Optional[Tuple[str, str]]: """Fetch a revision and create a fake-am commit range. @@ -2923,8 +3295,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): for msg in msgs: lmbx.add_message(msg) - lser = lmbx.get_series(rev, sloppytrailers=False, - codereview_trailers=False) + lser = lmbx.get_series(rev, sloppytrailers=False, codereview_trailers=False) if lser is None: logger.critical('Could not find series v%d in retrieved messages', rev) return None @@ -2998,9 +3369,12 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # --- Run git range-diff --- logger.info('Running range-diff...') - gitargs = ['range-diff', '--color', - f'{left_start}..{left_end}', - f'{right_start}..{right_end}'] + gitargs = [ + 'range-diff', + '--color', + f'{left_start}..{left_end}', + f'{right_start}..{right_end}', + ] ecode, out = b4.git_run_command(topdir, gitargs) if ecode != 0: logger.critical('git range-diff failed (exit %d)', ecode) @@ -3010,42 +3384,49 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return if not out.strip(): - logger.info('No differences found between v%d and v%d', - min(other_rev, current_rev), max(other_rev, current_rev)) + logger.info( + 'No differences found between v%d and v%d', + min(other_rev, current_rev), + max(other_rev, current_rev), + ) _wait_for_enter() return b4.view_in_pager(out.encode(), filehint='range-diff.txt') - def _delete_review_branch(self, topdir: str, review_branch: str, - notify: bool = True) -> bool: + def _delete_review_branch( + self, topdir: str, review_branch: str, notify: bool = True + ) -> bool: """Delete a review branch, switching away if currently on it. Returns True on success, False on failure. """ if b4.git_get_current_branch(topdir) == review_branch: ecode, out = b4.git_run_command( - topdir, ['rev-parse', f'{review_branch}~1'], - logstderr=True) + topdir, ['rev-parse', f'{review_branch}~1'], logstderr=True + ) if ecode > 0: if notify: - self.notify('Could not determine parent commit', - severity='error') + self.notify('Could not determine parent commit', severity='error') return False parent = out.strip() ecode, out = b4.git_run_command( - topdir, ['checkout', parent], logstderr=True) + topdir, ['checkout', parent], logstderr=True + ) if ecode > 0: if notify: - self.notify(f'Could not switch away from {review_branch}', - severity='error') + self.notify( + f'Could not switch away from {review_branch}', severity='error' + ) return False ecode, out = b4.git_run_command( - topdir, ['branch', '-D', review_branch], logstderr=True) + topdir, ['branch', '-D', review_branch], logstderr=True + ) if ecode > 0: if notify: - self.notify(f'Failed to delete branch {review_branch}', - severity='error') + self.notify( + f'Failed to delete branch {review_branch}', severity='error' + ) return False return True @@ -3058,16 +3439,25 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): review_branch = f'b4/review/{change_id}' has_branch = b4.git_branch_exists(None, review_branch) self.push_screen( - AbandonConfirmScreen(change_id, review_branch, has_branch, - subject=self._selected_series.get('subject', '')), + AbandonConfirmScreen( + change_id, + review_branch, + has_branch, + subject=self._selected_series.get('subject', ''), + ), callback=lambda confirmed: self._on_abandon_confirmed( - confirmed, change_id, review_branch, has_branch, - revision=revision), + confirmed, change_id, review_branch, has_branch, revision=revision + ), ) - def _on_abandon_confirmed(self, confirmed: bool, change_id: str, - review_branch: str, has_branch: bool, - revision: Optional[int] = None) -> None: + def _on_abandon_confirmed( + self, + confirmed: bool, + change_id: str, + review_branch: str, + has_branch: bool, + revision: Optional[int] = None, + ) -> None: if not confirmed: return topdir = b4.git_get_toplevel() @@ -3098,8 +3488,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return status = self._selected_series.get('status', 'new') if status not in ('reviewing', 'new'): - self.notify('Series must be checked out or new to upgrade', - severity='warning') + self.notify( + 'Series must be checked out or new to upgrade', severity='warning' + ) return change_id = self._selected_series.get('change_id', '') @@ -3108,12 +3499,13 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Discover newer revisions from tracking data and DB newer_versions = self._discover_newer_versions( - change_id, current_rev, review_branch) + change_id, current_rev, review_branch + ) if not newer_versions: self.notify( - 'No newer revisions known. Try \\[u]pdate first.', - severity='warning') + 'No newer revisions known. Try \\[u]pdate first.', severity='warning' + ) return # Look up revision metadata from the DB @@ -3128,42 +3520,44 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): newer_revs = [r for r in revisions if r['revision'] in newer_versions] if not newer_revs: self.notify( - 'No newer revisions known. Try \\[u]pdate first.', - severity='warning') + 'No newer revisions known. Try \\[u]pdate first.', severity='warning' + ) return if status == 'new': # No review branch — just update the DB record if len(newer_revs) == 1: - self._do_switch_revision(change_id, current_rev, - newer_revs[0]) + self._do_switch_revision(change_id, current_rev, newer_revs[0]) return self.push_screen( UpdateRevisionScreen(current_rev, revisions), callback=lambda chosen: ( self._switch_revision_by_number( - change_id, current_rev, chosen, revisions) - if chosen is not None else None + change_id, current_rev, chosen, revisions + ) + if chosen is not None + else None ), ) return if len(newer_revs) == 1: # Only one newer revision — go straight to the upgrade - self._do_update_revision(change_id, current_rev, - newer_revs[0]['revision']) + self._do_update_revision(change_id, current_rev, newer_revs[0]['revision']) return self.push_screen( UpdateRevisionScreen(current_rev, revisions), callback=lambda chosen: ( self._do_update_revision(change_id, current_rev, chosen) - if chosen is not None else None + if chosen is not None + else None ), ) - def _do_switch_revision(self, change_id: str, current_rev: int, - rev_info: Dict[str, Any]) -> None: + def _do_switch_revision( + self, change_id: str, current_rev: int, rev_info: Dict[str, Any] + ) -> None: """Switch a not-yet-checked-out series to a different revision. Simply updates the database record — no branch operations needed. @@ -3174,8 +3568,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: conn = b4.review.tracking.get_db(self._identifier) b4.review.tracking.update_series_revision( - conn, change_id, current_rev, target_rev, - new_msgid, new_subject) + conn, change_id, current_rev, target_rev, new_msgid, new_subject + ) conn.close() except Exception as ex: self.notify(f'Could not update revision: {ex}', severity='error') @@ -3185,9 +3579,13 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._invalidate_caches(change_id) self._load_series() - def _switch_revision_by_number(self, change_id: str, current_rev: int, - chosen: int, - revisions: List[Dict[str, Any]]) -> None: + def _switch_revision_by_number( + self, + change_id: str, + current_rev: int, + chosen: int, + revisions: List[Dict[str, Any]], + ) -> None: """Callback wrapper: find the revision dict and call _do_switch_revision.""" for rev in revisions: if rev['revision'] == chosen: @@ -3195,8 +3593,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return self.notify(f'Revision v{chosen} not found', severity='error') - def _do_update_revision(self, change_id: str, current_rev: int, - target_rev: int) -> None: + def _do_update_revision( + self, change_id: str, current_rev: int, target_rev: int + ) -> None: """Upgrade the review branch from *current_rev* to *target_rev*. Uses a three-phase workflow so the old review branch is never @@ -3227,8 +3626,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): break if not target_msgid: - self.notify(f'No message-id recorded for v{target_rev}', - severity='error') + self.notify(f'No message-id recorded for v{target_rev}', severity='error') return # Phase 1: fetch series and compute base in a worker thread @@ -3237,13 +3635,20 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): target_series = dict(self._selected_series or {}) target_series['message_id'] = target_msgid target_series['revision'] = target_rev - msgs = b4.review.retrieve_series_messages(target_series, self._identifier) + msgs = b4.review.retrieve_series_messages( + target_series, self._identifier + ) lser = b4.review._get_lore_series(msgs) am_msgs = lser.get_am_ready( - noaddtrailers=True, addmysob=False, addlink=False, - cherrypick=None, copyccs=False, allowbadchars=False, - showchecks=False) + noaddtrailers=True, + addmysob=False, + addlink=False, + cherrypick=None, + copyccs=False, + allowbadchars=False, + showchecks=False, + ) if not am_msgs: raise LookupError('No patches ready for applying') @@ -3267,23 +3672,25 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: guessed, nblobs, mismatches = lser.find_base( topdir, - branches=['--exclude=refs/heads/b4/review/*', - '--all'], - maxdays=30) + branches=['--exclude=refs/heads/b4/review/*', '--all'], + maxdays=30, + ) if guessed: ecode, sha_out = b4.git_run_command( - topdir, ['rev-parse', '--verify', guessed]) + topdir, ['rev-parse', '--verify', guessed] + ) sha = sha_out.strip() if ecode == 0 else '' short_sha = sha[:12] if sha else guessed if mismatches == 0: initial_base = short_sha - base_hint = (f'Guessed base: {guessed}' - f' (exact match)') + base_hint = f'Guessed base: {guessed} (exact match)' elif nblobs != mismatches: matched = nblobs - mismatches initial_base = short_sha - base_hint = (f'Guessed base: {guessed}' - f' ({matched}/{nblobs} blobs)') + base_hint = ( + f'Guessed base: {guessed}' + f' ({matched}/{nblobs} blobs)' + ) else: base_hint = 'Could not find a matching base' except (IndexError, Exception): @@ -3294,15 +3701,26 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self.push_screen( WorkerScreen('Fetching new revision\u2026', _fetch_update), callback=lambda result: self._on_update_prepared( - result, change_id, current_rev, target_rev, - target_msgid, target_subject, review_branch), + result, + change_id, + current_rev, + target_rev, + target_msgid, + target_subject, + review_branch, + ), ) - def _on_update_prepared(self, result: Any, - change_id: str, current_rev: int, - target_rev: int, target_msgid: str, - target_subject: str, - review_branch: str) -> None: + def _on_update_prepared( + self, + result: Any, + change_id: str, + current_rev: int, + target_rev: int, + target_msgid: str, + target_subject: str, + review_branch: str, + ) -> None: """Phase 2: show BaseSelectionScreen after fetching the new series.""" if result is None: return @@ -3325,22 +3743,41 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): base_suggestions.append(rb) self.push_screen( - BaseSelectionScreen(initial_base, lser, ambytes, - base_suggestions=base_suggestions, - base_hint=base_hint, - subject=target_subject), + BaseSelectionScreen( + initial_base, + lser, + ambytes, + base_suggestions=base_suggestions, + base_hint=base_hint, + subject=target_subject, + ), callback=lambda base_sha: self._on_update_base_selected( - base_sha, lser, ambytes, num_am, change_id, current_rev, - target_rev, target_msgid, target_subject, - review_branch), + base_sha, + lser, + ambytes, + num_am, + change_id, + current_rev, + target_rev, + target_msgid, + target_subject, + review_branch, + ), ) def _on_update_base_selected( - self, base_sha: Optional[str], - lser: b4.LoreSeries, ambytes: bytes, num_am: int, - change_id: str, current_rev: int, target_rev: int, - target_msgid: str, target_subject: str, - review_branch: str) -> None: + self, + base_sha: Optional[str], + lser: b4.LoreSeries, + ambytes: bytes, + num_am: int, + change_id: str, + current_rev: int, + target_rev: int, + target_msgid: str, + target_subject: str, + review_branch: str, + ) -> None: """Phase 3: apply new revision to upgrade branch, then swap. The old review branch is never touched until the apply succeeds. @@ -3385,7 +3822,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): usercfg = b4.get_user_config() maintainer_email = str(usercfg.get('email', '')) prior_context = b4.review.tracking.render_prior_review_context( - maintainer_email, current_rev, old_series, patches) + maintainer_email, current_rev, old_series, patches + ) prior_thread_blob = old_series.get('thread-context-blob', '') prior_msgid = old_series.get('header-info', {}).get('msgid', '') @@ -3398,11 +3836,13 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: _conn = b4.review.tracking.get_db(self._identifier) b4.review.tracking.set_revision_thread_blob( - _conn, change_id, current_rev, cur_mbox_blob) + _conn, change_id, current_rev, cur_mbox_blob + ) _conn.close() except Exception as _ex: - logger.debug('Could not record thread blob for v%d: %s', - current_rev, _ex) + logger.debug( + 'Could not record thread blob for v%d: %s', current_rev, _ex + ) # --- 2. Resolve metadata for git-am --- top_msgid = None @@ -3432,53 +3872,73 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if mismatches: rstart, rend = lser.make_fake_am_range(gitdir=topdir) if rstart and rend: - logger.info('Prepared fake commit range for 3-way merge (%.12s..%.12s)', rstart, rend) + logger.info( + 'Prepared fake commit range for 3-way merge (%.12s..%.12s)', + rstart, + rend, + ) # --- 3. Apply to temporary upgrade branch --- try: logger.info('Base: %s', base_sha) - b4.git_fetch_am_into_repo(topdir, ambytes=ambytes, - at_base=base_sha, origin=linkurl, - am_flags=['-3']) + b4.git_fetch_am_into_repo( + topdir, + ambytes=ambytes, + at_base=base_sha, + origin=linkurl, + am_flags=['-3'], + ) _is_rt = bool((self._selected_series or {}).get('is_rethreaded')) - b4.review.create_review_branch(topdir, upgrade_branch, - base_sha, lser, linkurl, - linkmask, num_prereqs=0, - identifier=self._identifier, - status='reviewing', - is_rethreaded=_is_rt) + b4.review.create_review_branch( + topdir, + upgrade_branch, + base_sha, + lser, + linkurl, + linkmask, + num_prereqs=0, + identifier=self._identifier, + status='reviewing', + is_rethreaded=_is_rt, + ) logger.info('Upgrade branch created: %s', upgrade_branch) except b4.AmConflictError as cex: if not _resolve_worktree_am_conflict(topdir, cex): # User aborted — clean up upgrade branch if it was # partially created before the conflict if b4.git_branch_exists(topdir, upgrade_branch): - b4.git_run_command( - topdir, ['branch', '-D', upgrade_branch]) + b4.git_run_command(topdir, ['branch', '-D', upgrade_branch]) _wait_for_enter() return b4._rewrite_fetch_head_origin(topdir, cex.worktree_path, linkurl) - b4.review.create_review_branch(topdir, upgrade_branch, - base_sha, lser, linkurl, - linkmask, num_prereqs=0, - identifier=self._identifier, - status='reviewing', - is_rethreaded=_is_rt) + b4.review.create_review_branch( + topdir, + upgrade_branch, + base_sha, + lser, + linkurl, + linkmask, + num_prereqs=0, + identifier=self._identifier, + status='reviewing', + is_rethreaded=_is_rt, + ) logger.info('Upgrade branch created: %s', upgrade_branch) except Exception as ex: logger.critical('Error creating review branch: %s', ex) if b4.git_branch_exists(topdir, upgrade_branch): - b4.git_run_command( - topdir, ['branch', '-D', upgrade_branch]) + b4.git_run_command(topdir, ['branch', '-D', upgrade_branch]) _wait_for_enter() return # --- 4. Apply succeeded — restore reviews onto upgrade branch --- logger.info('Restoring reviews...') new_patch_ids = b4.review.get_review_branch_patch_ids( - topdir, upgrade_branch) + topdir, upgrade_branch + ) new_cover_text, new_tracking = b4.review.load_tracking( - topdir, upgrade_branch) + topdir, upgrade_branch + ) new_patches = new_tracking.get('patches', []) usercfg = b4.get_user_config() @@ -3491,7 +3951,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # sent anything yet (e.g. the submitter sent a new version while # the maintainer was still drafting their review). old_status = old_series.get('status', '') - carry_over_reviews = (old_status != 'replied') + carry_over_reviews = old_status != 'replied' restored = 0 for idx, _sha, patch_id in new_patch_ids: if patch_id is None or idx >= len(new_patches): @@ -3508,7 +3968,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # _get_patch_state() will override this with any derived state # (draft, done, …) if the maintainer already has review content. my_review = reviews.setdefault( - my_email, {'name': str(usercfg.get('name', ''))}) + my_email, {'name': str(usercfg.get('name', ''))} + ) my_review['patch-state'] = 'unchanged' reviews['b4-upgrade@internal'] = { 'name': 'B4 Upgrade', @@ -3532,18 +3993,25 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): new_tracking['series']['prior-thread-context-blob'] = prior_thread_blob if prior_msgid: new_tracking['series']['prior-revision-msgid'] = prior_msgid - b4.review.save_tracking_ref(topdir, upgrade_branch, - new_cover_text, new_tracking) - logger.info('Restored reviews for %d of %d patch(es)', - restored, len(new_patch_ids)) + b4.review.save_tracking_ref( + topdir, upgrade_branch, new_cover_text, new_tracking + ) + logger.info( + 'Restored reviews for %d of %d patch(es)', restored, len(new_patch_ids) + ) # --- 5. Archive old branch and rename upgrade → review --- logger.info('Archiving v%d...', current_rev) pw_series_id = None if self._selected_series: pw_series_id = self._selected_series.get('pw_series_id') - if not self._archive_branch(change_id, current_rev, review_branch, - pw_series_id=pw_series_id, notify=False): + if not self._archive_branch( + change_id, + current_rev, + review_branch, + pw_series_id=pw_series_id, + notify=False, + ): logger.critical('Failed to archive v%d', current_rev) # Upgrade branch has new data but old branch could not be # archived. Leave both branches so the user can recover. @@ -3551,10 +4019,10 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): return ecode, out = b4.git_run_command( - topdir, ['branch', '-m', upgrade_branch, review_branch]) + topdir, ['branch', '-m', upgrade_branch, review_branch] + ) if ecode > 0: - logger.critical('Failed to rename upgrade branch: %s', - out.strip()) + logger.critical('Failed to rename upgrade branch: %s', out.strip()) _wait_for_enter() return @@ -3573,16 +4041,22 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if ref_msg and ref_msg.date: sent_at = ref_msg.date.isoformat() b4.review.tracking.add_series_to_db( - conn, change_id, target_rev, - target_subject, sender_name, sender_email, - sent_at, target_msgid, - lser.expected or num_am) + conn, + change_id, + target_rev, + target_subject, + sender_name, + sender_email, + sent_at, + target_msgid, + lser.expected or num_am, + ) b4.review.tracking.update_series_status( - conn, change_id, 'reviewing', revision=target_rev) + conn, change_id, 'reviewing', revision=target_rev + ) conn.close() except Exception as ex: - logger.warning('Failed to update DB for v%d: %s', - target_rev, ex) + logger.warning('Failed to update DB for v%d: %s', target_rev, ex) logger.info('Upgrade to v%d complete', target_rev) _wait_for_enter() @@ -3604,7 +4078,8 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: conn = b4.review.tracking.get_db(self._identifier) b4.review.tracking.update_series_status( - conn, change_id, 'waiting', revision=revision) + conn, change_id, 'waiting', revision=revision + ) conn.close() except Exception as ex: self.notify(f'Error: {ex}', severity='error') @@ -3627,9 +4102,11 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self.notify('Cannot snooze a series in this state', severity='warning') return self.push_screen( - SnoozeScreen(last_source=self._last_snooze_source, - last_input=self._last_snooze_input, - subject=self._selected_series.get('subject', '')), + SnoozeScreen( + last_source=self._last_snooze_source, + last_input=self._last_snooze_input, + subject=self._selected_series.get('subject', ''), + ), callback=self._on_snooze_confirmed, ) @@ -3665,8 +4142,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Update database try: conn = b4.review.tracking.get_db(self._identifier) - b4.review.tracking.snooze_series(conn, change_id, until_value, - revision=revision) + b4.review.tracking.snooze_series( + conn, change_id, until_value, revision=revision + ) conn.close() except Exception as ex: self.notify(f'Error: {ex}', severity='error') @@ -3703,8 +4181,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Update database try: conn = b4.review.tracking.get_db(self._identifier) - b4.review.tracking.unsnooze_series(conn, change_id, previous_status, - revision=revision) + b4.review.tracking.unsnooze_series( + conn, change_id, previous_status, revision=revision + ) conn.close() except Exception as ex: self.notify(f'Error: {ex}', severity='error') @@ -3725,16 +4204,30 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): review_branch = f'b4/review/{change_id}' has_branch = b4.git_branch_exists(None, review_branch) self.push_screen( - ArchiveConfirmScreen(change_id, review_branch, has_branch, - subject=self._selected_series.get('subject', '')), + ArchiveConfirmScreen( + change_id, + review_branch, + has_branch, + subject=self._selected_series.get('subject', ''), + ), callback=lambda confirmed: self._on_archive_confirmed( - confirmed, change_id, review_branch, has_branch, pw_series_id, - revision=revision), + confirmed, + change_id, + review_branch, + has_branch, + pw_series_id, + revision=revision, + ), ) - def _archive_branch(self, change_id: str, revision: Optional[int], - review_branch: str, pw_series_id: Optional[int] = None, - notify: bool = True) -> bool: + def _archive_branch( + self, + change_id: str, + revision: Optional[int], + review_branch: str, + pw_series_id: Optional[int] = None, + notify: bool = True, + ) -> bool: """Archive a review branch and update the tracking database. Creates a tar.gz archive of the cover letter, tracking metadata, @@ -3769,8 +4262,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): first_patch = series_info.get('first-patch-commit', '') if not first_patch: if notify: - self.notify('No patch commits found in tracking data', - severity='error') + self.notify( + 'No patch commits found in tracking data', severity='error' + ) return False with tarfile.open(fileobj=tio, mode='w:gz') as tfh: @@ -3786,12 +4280,12 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): ifh.close() # Add patches as mbox patches = b4.git_range_to_patches( - None, f'{first_patch}~1', f'{review_branch}~1') + None, f'{first_patch}~1', f'{review_branch}~1' + ) if patches: ifh = io.BytesIO() b4.save_git_am_mbox([patch[1] for patch in patches], ifh) - b4.ez.write_to_tar( - tfh, f'{change_id}/patches.mbx', mnow, ifh) + b4.ez.write_to_tar(tfh, f'{change_id}/patches.mbx', mnow, ifh) ifh.close() # Write archive to data directory @@ -3809,8 +4303,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): # Update tracking database try: conn = b4.review.tracking.get_db(self._identifier) - b4.review.tracking.update_series_status(conn, change_id, 'archived', - revision=revision) + b4.review.tracking.update_series_status( + conn, change_id, 'archived', revision=revision + ) conn.close() except Exception as ex: if notify: @@ -3828,14 +4323,20 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self.notify(f'Archived {change_id}') return True - def _on_archive_confirmed(self, confirmed: bool, change_id: str, - review_branch: str, has_branch: bool, - pw_series_id: Optional[int] = None, - revision: Optional[int] = None) -> None: + def _on_archive_confirmed( + self, + confirmed: bool, + change_id: str, + review_branch: str, + has_branch: bool, + pw_series_id: Optional[int] = None, + revision: Optional[int] = None, + ) -> None: if not confirmed: return - if self._archive_branch(change_id, revision, review_branch, - pw_series_id=pw_series_id): + if self._archive_branch( + change_id, revision, review_branch, pw_series_id=pw_series_id + ): self._selected_series = None panel = self.query_one('#details-panel', Vertical) panel.styles.height = 0 @@ -3853,7 +4354,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if not series: return if series.get('status', 'new') != 'accepted': - self.notify('Series must be accepted before sending thanks', severity='warning') + self.notify( + 'Series must be accepted before sending thanks', severity='warning' + ) return change_id = series.get('change_id', '') @@ -3941,8 +4444,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self._show_thank_preview(msg, checkurl=checkurl) - def _show_thank_preview(self, msg: email.message.EmailMessage, - checkurl: Optional[str] = None) -> None: + def _show_thank_preview( + self, msg: email.message.EmailMessage, checkurl: Optional[str] = None + ) -> None: """Push the ThankScreen modal and handle edit/send/queue/cancel.""" def _on_thank_result(result: Optional[str]) -> None: @@ -3957,8 +4461,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): self.push_screen(ThankScreen(msg, checkurl=checkurl), _on_thank_result) - def _edit_thank_message(self, msg: email.message.EmailMessage, - checkurl: Optional[str] = None) -> None: + def _edit_thank_message( + self, msg: email.message.EmailMessage, checkurl: Optional[str] = None + ) -> None: """Open the thank-you message in $EDITOR and re-show preview.""" msg_bytes = msg.as_bytes(policy=b4.emlpolicy) try: @@ -3970,7 +4475,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): new_msg = email.parser.BytesParser(policy=b4.emlpolicy).parsebytes(edited) self._show_thank_preview(new_msg, checkurl=checkurl) - def _queue_thank_message(self, msg: email.message.EmailMessage, checkurl: str) -> None: + def _queue_thank_message( + self, msg: email.message.EmailMessage, checkurl: str + ) -> None: """Queue the thanks message for delivery once commits are public.""" import b4.ty @@ -3980,9 +4487,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): change_id = series.get('change_id', '') revision = series.get('revision', 1) try: - b4.ty.queue_message(msg, checkurl, - change_id, revision, - dryrun=self._email_dryrun) + b4.ty.queue_message( + msg, checkurl, change_id, revision, dryrun=self._email_dryrun + ) except Exception as ex: self.notify(f'Failed to queue message: {ex}', severity='error') return @@ -3998,9 +4505,15 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: with self.suspend(): smtp, fromaddr = b4.get_smtp(dryrun=self._email_dryrun) - sent = b4.send_mail(smtp, [msg], fromaddr=fromaddr, - patatt_sign=self._patatt_sign, dryrun=self._email_dryrun, - output_dir=None, reflect=False) + sent = b4.send_mail( + smtp, + [msg], + fromaddr=fromaddr, + patatt_sign=self._patatt_sign, + dryrun=self._email_dryrun, + output_dir=None, + reflect=False, + ) if sent is None: self.notify('Failed to send thank-you message', severity='error') return @@ -4010,8 +4523,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): if self._identifier and change_id: try: conn = b4.review.tracking.get_db(self._identifier) - b4.review.tracking.update_series_status(conn, change_id, 'thanked', - revision=revision) + b4.review.tracking.update_series_status( + conn, change_id, 'thanked', revision=revision + ) conn.close() except Exception as ex: logger.warning('Could not update series status: %s', ex) @@ -4029,6 +4543,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): def _refresh_queue_indicator(self) -> None: """Update the title-bar queue count and Q binding visibility.""" import b4.ty + self._queue_count = b4.ty.get_queued_count(dryrun=self._email_dryrun) try: right = self.query_one('#title-right', Static) @@ -4043,6 +4558,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): def action_process_queue(self) -> None: """Show the queue modal and optionally deliver.""" import b4.ty + entries = b4.ty.get_queued_messages(dryrun=self._email_dryrun) if not entries: self.notify('No queued thanks messages') @@ -4057,7 +4573,9 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): def _deliver_queue(self) -> None: """Push a delivery modal with progress bar.""" - def _on_delivery_result(result: Optional[Tuple[int, int, List[Tuple[str, int]]]]) -> None: + def _on_delivery_result( + result: Optional[Tuple[int, int, List[Tuple[str, int]]]], + ) -> None: if result is None: self.notify('Queue delivery cancelled or failed', severity='warning') self._refresh_queue_indicator() @@ -4069,14 +4587,17 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): try: conn = b4.review.tracking.get_db(self._identifier) b4.review.tracking.update_series_status( - conn, change_id, 'thanked', revision=revision) + conn, change_id, 'thanked', revision=revision + ) conn.close() except Exception as ex: logger.warning('Could not update series status: %s', ex) topdir = b4.git_get_toplevel() if topdir: review_branch = f'b4/review/{change_id}' - b4.review.update_tracking_status(topdir, review_branch, 'thanked') + b4.review.update_tracking_status( + topdir, review_branch, 'thanked' + ) parts = [] if delivered: parts.append(f'{delivered} delivered') @@ -4104,8 +4625,10 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): def action_patchwork(self) -> None: """Exit to the outer loop so it can launch the Patchwork TUI.""" if not (self._pwkey and self._pwurl and self._pwproj): - self.notify('Patchwork not configured (need b4.pw-key, b4.pw-url, b4.pw-project)', - severity='error') + self.notify( + 'Patchwork not configured (need b4.pw-key, b4.pw-url, b4.pw-project)', + severity='error', + ) return self.exit(self.PATCHWORK_SENTINEL) @@ -4120,4 +4643,3 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]): async def action_quit(self) -> None: self.exit() - diff --git a/src/b4/tui/__init__.py b/src/b4/tui/__init__.py index be4699a..a7b3598 100644 --- a/src/b4/tui/__init__.py +++ b/src/b4/tui/__init__.py @@ -1,4 +1,5 @@ """Shared TUI utilities and widgets for b4 Textual apps.""" + __all__ = [ 'ActionItem', 'ActionScreen', diff --git a/src/b4/tui/_common.py b/src/b4/tui/_common.py index 8eb6c45..b788f35 100644 --- a/src/b4/tui/_common.py +++ b/src/b4/tui/_common.py @@ -4,6 +4,7 @@ # Copyright (C) 2024 by the Linux Foundation # """Shared TUI utilities for b4 Textual apps.""" + __author__ = 'Konstantin Ryabitsev <[email protected]>' import email.utils @@ -108,15 +109,14 @@ def ci_styles(ts: Dict[str, str]) -> Dict[str, str]: 'pending': 'dim', 'success': ts['success'], 'warning': ts['warning'], - 'fail': f"bold {ts['error']}", + 'fail': f'bold {ts["error"]}', } def ci_markup(ts: Dict[str, str]) -> Dict[str, str]: """Return CI dot markup strings from a resolved theme dict.""" return { - state: f'[{style}]\u25cf[/{style}]' - for state, style in ci_styles(ts).items() + state: f'[{style}]\u25cf[/{style}]' for state, style in ci_styles(ts).items() } @@ -126,7 +126,7 @@ def ci_check_styles(ts: Dict[str, str]) -> Dict[str, str]: 'pending': 'dim', 'success': ts['success'], 'warning': ts['warning'], - 'fail': f"bold {ts['error']}", + 'fail': f'bold {ts["error"]}', } @@ -136,7 +136,7 @@ def reviewer_colours(ts: Dict[str, str]) -> List[str]: Index 0 is always the current user; the rest cycle for others. """ return [ - ts['warning'], # index 0: current user (warm/distinct) + ts['warning'], # index 0: current user (warm/distinct) ts['accent'], ts['secondary'], ts['error'], @@ -189,7 +189,9 @@ def _suspend_to_shell(hint: str = 'b4', cwd: Optional[str] = None) -> None: is set so the user can incorporate it into their own prompt. """ logger.info('---') - logger.info('You are now in shell mode. You can execute git commands or run checks.') + logger.info( + 'You are now in shell mode. You can execute git commands or run checks.' + ) logger.info('Cosmetic commit edits (reword subjects, fix trailers) are fine;') logger.info('b4 will reconcile tracking data when you return.') logger.info('Do NOT add, remove, squash, or reorder commits.') @@ -205,8 +207,9 @@ def _suspend_to_shell(hint: str = 'b4', cwd: Optional[str] = None) -> None: bashrc = os.path.expanduser('~/.bashrc') source = f'[ -f {bashrc} ] && . {bashrc}\n' source += f'PS1="({hint}) $PS1"\n' - with tempfile.NamedTemporaryFile(mode='w', prefix='b4-shell-', - suffix='.sh', delete=False) as rcf: + with tempfile.NamedTemporaryFile( + mode='w', prefix='b4-shell-', suffix='.sh', delete=False + ) as rcf: rcf.write(source) rcfile = rcf.name try: diff --git a/src/b4/tui/_modals.py b/src/b4/tui/_modals.py index 15f2e3b..da3ca13 100644 --- a/src/b4/tui/_modals.py +++ b/src/b4/tui/_modals.py @@ -4,6 +4,7 @@ # Copyright (C) 2024 by the Linux Foundation # """Shared modal screens for b4 Textual apps.""" + __author__ = 'Konstantin Ryabitsev <[email protected]>' from typing import TYPE_CHECKING, Dict, List, Optional, Tuple @@ -87,7 +88,9 @@ class ToCcScreen(ModalScreen[bool]): yield TextArea(self._bcc_text, id='bcc-area', classes='tocc-area') if self._show_apply_all: yield Checkbox('Apply to all patches', id='apply-all') - yield Static('Ctrl+S save | Escape cancel | Tab next field', id='tocc-hint') + yield Static( + 'Ctrl+S save | Escape cancel | Tab next field', id='tocc-hint' + ) def on_mount(self) -> None: self.query_one('#to-area', TextArea).focus() @@ -165,10 +168,14 @@ class ConfirmScreen(ModalScreen[bool]): # Map CSS variable names to CSS class suffixes for border/title colours. _COLOUR_CLASSES = {'$warning': 'warning', '$error': 'error'} - def __init__(self, title: str, body: List[str], - border: str = '$accent', - title_colour: Optional[str] = None, - subject: str = '') -> None: + def __init__( + self, + title: str, + body: List[str], + border: str = '$accent', + title_colour: Optional[str] = None, + subject: str = '', + ) -> None: super().__init__() self._title = title self._body = body @@ -226,9 +233,12 @@ class LimitScreen(ModalScreen[Optional[str]]): } """ - def __init__(self, current_pattern: str = '', - hint: Optional[str] = None, - title: str = 'Limit') -> None: + def __init__( + self, + current_pattern: str = '', + hint: Optional[str] = None, + title: str = 'Limit', + ) -> None: super().__init__() self._current_pattern = current_pattern self._hint = hint @@ -237,8 +247,11 @@ class LimitScreen(ModalScreen[Optional[str]]): def compose(self) -> ComposeResult: with Vertical(id='limit-dialog') as dialog: dialog.border_title = self._title - yield Input(value=self._current_pattern, id='limit-input', - placeholder='substring to match (empty to clear)') + yield Input( + value=self._current_pattern, + id='limit-input', + placeholder='substring to match (empty to clear)', + ) hint_lines = '' if self._hint: hint_lines = self._hint + '\n' @@ -303,8 +316,9 @@ class ActionScreen(JKListNavMixin, ModalScreen[Optional[str]]): } """ - def __init__(self, actions: List[Tuple[str, str]], - shortcuts: Optional[Dict[str, str]] = None) -> None: + def __init__( + self, actions: List[Tuple[str, str]], shortcuts: Optional[Dict[str, str]] = None + ) -> None: super().__init__() self._actions = actions self._shortcuts = shortcuts or {} diff --git a/src/b4/ty.py b/src/b4/ty.py index 5786222..45c7773 100644 --- a/src/b4/ty.py +++ b/src/b4/ty.py @@ -25,7 +25,8 @@ JsonDictT = Dict[str, Union[str, int, List[Any], Dict[str, Any]]] logger = b4.logger -DEFAULT_PR_TEMPLATE = """ +DEFAULT_PR_TEMPLATE = ( + """ On ${sentdate}, ${fromname} wrote: ${quote} @@ -34,11 +35,15 @@ Merged, thanks! ${summary} Best regards, ---""" + ' ' + """ +--""" + + ' ' + + """ ${signature} """ +) -DEFAULT_AM_TEMPLATE = """ +DEFAULT_AM_TEMPLATE = ( + """ On ${sentdate}, ${fromname} wrote: ${quote} @@ -47,9 +52,12 @@ Applied, thanks! ${summary} Best regards, ---""" + ' ' + """ +--""" + + ' ' + + """ ${signature} """ +) # Used to track commits created by current user MY_COMMITS: Optional[Dict[str, Tuple[str, str, List[str]]]] = None @@ -57,7 +65,9 @@ MY_COMMITS: Optional[Dict[str, Tuple[str, str, List[str]]]] = None BRANCH_INFO: Optional[Dict[str, str]] = None -def git_get_merge_id(gitdir: Optional[str], commit_id: str, branch: Optional[str] = None) -> Optional[str]: +def git_get_merge_id( + gitdir: Optional[str], commit_id: str, branch: Optional[str] = None +) -> Optional[str]: # get merge commit id args = ['rev-list', '%s..' % commit_id, '--ancestry-path'] if branch is not None: @@ -78,7 +88,12 @@ def git_get_commit_message(gitdir: Optional[str], rev: str) -> Tuple[int, str]: return b4.git_run_command(gitdir, args) -def make_reply(reply_template: str, jsondata: JsonDictT, gitdir: Optional[str], cmdargs: argparse.Namespace) -> EmailMessage: +def make_reply( + reply_template: str, + jsondata: JsonDictT, + gitdir: Optional[str], + cmdargs: argparse.Namespace, +) -> EmailMessage: msg = EmailMessage() msg['From'] = '%s <%s>' % (jsondata['myname'], jsondata['myemail']) excludes = b4.get_excluded_addrs() @@ -87,14 +102,20 @@ def make_reply(reply_template: str, jsondata: JsonDictT, gitdir: Optional[str], assert isinstance(jsondata['to'], str), 'to must be a string' assert isinstance(jsondata['cc'], str), 'cc must be a string' assert isinstance(jsondata['myemail'], str), 'msgid must be a string' - newto = b4.cleanup_email_addrs([(jsondata['fromname'], jsondata['fromemail'])], excludes, gitdir) + newto = b4.cleanup_email_addrs( + [(jsondata['fromname'], jsondata['fromemail'])], excludes, gitdir + ) # Exclude ourselves and original sender from allto or allcc if not cmdargs.metoo: excludes.add(jsondata['myemail']) excludes.add(jsondata['fromemail']) - allto = b4.cleanup_email_addrs(email.utils.getaddresses([jsondata['to']]), excludes, gitdir) - allcc = b4.cleanup_email_addrs(email.utils.getaddresses([jsondata['cc']]), excludes, gitdir) + allto = b4.cleanup_email_addrs( + email.utils.getaddresses([jsondata['to']]), excludes, gitdir + ) + allcc = b4.cleanup_email_addrs( + email.utils.getaddresses([jsondata['cc']]), excludes, gitdir + ) if newto: allto += newto @@ -124,7 +145,9 @@ def make_reply(reply_template: str, jsondata: JsonDictT, gitdir: Optional[str], return msg -def auto_locate_pr(gitdir: Optional[str], jsondata: JsonDictT, branch: str) -> Optional[str]: +def auto_locate_pr( + gitdir: Optional[str], jsondata: JsonDictT, branch: str +) -> Optional[str]: pr_commit_id = jsondata['pr_commit_id'] assert isinstance(pr_commit_id, str), 'pr_commit_id must be a string' logger.debug('Checking %s', jsondata['pr_commit_id']) @@ -161,8 +184,12 @@ def auto_locate_pr(gitdir: Optional[str], jsondata: JsonDictT, branch: str) -> O return merge_commit_id -def get_all_commits(gitdir: Optional[str], branch: str, since: str = '1.week', - committer: Optional[str] = None) -> Dict[str, Tuple[str, str, List[str]]]: +def get_all_commits( + gitdir: Optional[str], + branch: str, + since: str = '1.week', + committer: Optional[str] = None, +) -> Dict[str, Tuple[str, str, List[str]]]: global MY_COMMITS if MY_COMMITS is not None: return MY_COMMITS @@ -174,11 +201,23 @@ def get_all_commits(gitdir: Optional[str], branch: str, since: str = '1.week', if isinstance(_ce, str): committer = _ce else: - logger.critical('No committer email found in user config, please set user.email') + logger.critical( + 'No committer email found in user config, please set user.email' + ) sys.exit(1) - gitargs = ['log', '--committer', committer, '--no-mailmap', '--no-abbrev', '--no-decorate', - '--oneline', '--since', since, branch] + gitargs = [ + 'log', + '--committer', + committer, + '--no-mailmap', + '--no-abbrev', + '--no-decorate', + '--oneline', + '--since', + since, + branch, + ] lines = b4.git_get_command_lines(gitdir, gitargs) if not len(lines): logger.debug('No new commits from the current user --since=%s', since) @@ -194,7 +233,9 @@ def get_all_commits(gitdir: Optional[str], branch: str, since: str = '1.week', 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) + matches = re.findall( + r'^\s*(?:message-id|link):[ \t]+(\S+)\s*$', out, flags=re.I | re.M + ) trackers: List[str] = list() if matches: for tvalue in matches: @@ -205,8 +246,9 @@ def get_all_commits(gitdir: Optional[str], branch: str, since: str = '1.week', return MY_COMMITS -def auto_locate_series(gitdir: Optional[str], jsondata: JsonDictT, branch: str, - since: str = '1.week') -> List[Tuple[int, Optional[str]]]: +def auto_locate_series( + gitdir: Optional[str], jsondata: JsonDictT, branch: str, since: str = '1.week' +) -> List[Tuple[int, Optional[str]]]: commits = get_all_commits(gitdir, branch, since) patchids = set(commits.keys()) @@ -252,10 +294,9 @@ def auto_locate_series(gitdir: Optional[str], jsondata: JsonDictT, branch: str, return found -def set_branch_details(gitdir: Optional[str], - branch: str, - jsondata: JsonDictT, - config: ConfigDictT) -> Tuple[JsonDictT, ConfigDictT]: +def set_branch_details( + gitdir: Optional[str], branch: str, jsondata: JsonDictT, config: ConfigDictT +) -> Tuple[JsonDictT, ConfigDictT]: binfo = get_branch_info(gitdir, branch) jsondata['branch'] = branch for key, val in binfo.items(): @@ -286,7 +327,9 @@ def set_branch_details(gitdir: Optional[str], return jsondata, config -def generate_pr_thanks(gitdir: Optional[str], jsondata: JsonDictT, branch: str, cmdargs: argparse.Namespace) -> EmailMessage: +def generate_pr_thanks( + gitdir: Optional[str], jsondata: JsonDictT, branch: str, cmdargs: argparse.Namespace +) -> EmailMessage: config = b4.get_main_config() jsondata, config = set_branch_details(gitdir, branch, jsondata, config) thanks_template = DEFAULT_PR_TEMPLATE @@ -296,13 +339,17 @@ def generate_pr_thanks(gitdir: Optional[str], jsondata: JsonDictT, branch: str, try: thanks_template = b4.read_template(_ctpr) except FileNotFoundError: - logger.critical('ERROR: thanks-pr-template says to use %s, but it does not exist', - config['thanks-pr-template']) + 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: assert 'pr_commit_id' in jsondata, 'pr_commit_id must be present in jsondata' - assert isinstance(jsondata['pr_commit_id'], str), 'pr_commit_id must be a string' + assert isinstance(jsondata['pr_commit_id'], str), ( + 'pr_commit_id must be a string' + ) 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']) @@ -319,7 +366,9 @@ def generate_pr_thanks(gitdir: Optional[str], jsondata: JsonDictT, branch: str, return msg -def generate_am_thanks(gitdir: Optional[str], jsondata: JsonDictT, branch: str, cmdargs: argparse.Namespace) -> EmailMessage: +def generate_am_thanks( + gitdir: Optional[str], jsondata: JsonDictT, branch: str, cmdargs: argparse.Namespace +) -> EmailMessage: global BRANCH_INFO BRANCH_INFO = None config = b4.get_main_config() @@ -331,8 +380,10 @@ def generate_am_thanks(gitdir: Optional[str], jsondata: JsonDictT, branch: str, try: thanks_template = b4.read_template(_ctat) except FileNotFoundError: - logger.critical('ERROR: thanks-am-template says to use %s, but it does not exist', - config['thanks-am-template']) + 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, cmdargs.since) @@ -361,11 +412,17 @@ def generate_am_thanks(gitdir: Optional[str], jsondata: JsonDictT, branch: str, 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( + ' 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( + ' WARNING: Could not match %s of %s patches in: %s', + nomatch, + len(commits), + jsondata['subject'], + ) logger.critical(' Please review the resulting message') msg = make_reply(thanks_template, jsondata, gitdir, cmdargs) @@ -391,7 +448,9 @@ def auto_thankanator(cmdargs: argparse.Namespace) -> None: jsondata['merge_commit_id'] = merge_commit_id else: # This is a patch series - commits = auto_locate_series(gitdir, jsondata, wantbranch, since=cmdargs.since) + 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: @@ -413,7 +472,9 @@ def auto_thankanator(cmdargs: argparse.Namespace) -> None: sys.exit(0) -def send_messages(listing: List[JsonDictT], branch: str, cmdargs: argparse.Namespace) -> None: +def send_messages( + listing: List[JsonDictT], branch: str, cmdargs: argparse.Namespace +) -> None: logger.info('Generating %s thank-you letters', len(listing)) gitdir = cmdargs.gitdir datadir = b4.get_data_dir() @@ -474,7 +535,9 @@ def send_messages(listing: List[JsonDictT], branch: str, cmdargs: argparse.Names if send_email: if not fromaddr and isinstance(jsondata['myemail'], str): fromaddr = jsondata['myemail'] - logger.info(' Sending: %s', b4.LoreMessage.clean_header(msg.get('subject'))) + logger.info( + ' Sending: %s', b4.LoreMessage.clean_header(msg.get('subject')) + ) b4.send_mail(smtp, [msg], fromaddr, dryrun=cmdargs.dryrun) else: assert isinstance(jsondata['fromemail'], str), 'fromname must be a string' @@ -642,7 +705,9 @@ def check_stale_thanks(outdir: str) -> None: 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( + ' Please send them first (or delete if already sent).' + ) logger.critical(' Refusing to run to avoid potential confusion.') sys.exit(1) @@ -664,7 +729,10 @@ def get_wanted_branch(cmdargs: argparse.Namespace) -> str: 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) + logger.critical( + 'Requested branch not found in git branch --list --all %s', + cmdargs.branch, + ) sys.exit(1) wantbranch = cmdargs.branch @@ -704,9 +772,13 @@ def _parse_queue_file(filepath: str) -> Optional[EmailMessage]: return None -def queue_message(msg: EmailMessage, checkurl: str, - change_id: str, revision: int, - dryrun: bool = False) -> None: +def queue_message( + msg: EmailMessage, + checkurl: str, + change_id: str, + revision: int, + dryrun: bool = False, +) -> None: """Write a thanks message to the file-based queue.""" qdir = _get_queue_dir(dryrun=dryrun) os.makedirs(qdir, exist_ok=True) @@ -744,11 +816,13 @@ def get_queued_messages(dryrun: bool = False) -> List[Dict[str, str]]: continue subject = str(msg.get('Subject', '(no subject)')) checkurl = str(msg.get('X-Check-URL', '')) - results.append({ - 'filename': fname, - 'subject': subject, - 'checkurl': checkurl, - }) + results.append( + { + 'filename': fname, + 'subject': subject, + 'checkurl': checkurl, + } + ) return results @@ -786,9 +860,11 @@ def _parse_change_revision(fname: str) -> Tuple[str, int]: return (stem, 0) -def process_queue(dryrun: bool = False, - patatt_sign: bool = True, - progress_cb: ProgressCallbackT = None) -> QueueResultT: +def process_queue( + dryrun: bool = False, + patatt_sign: bool = True, + progress_cb: ProgressCallbackT = None, +) -> QueueResultT: """Check queued messages and deliver those whose commits are visible. *progress_cb*, when provided, is called as @@ -826,8 +902,7 @@ def process_queue(dryrun: bool = False, if not dryrun and checkurl: try: session = b4.get_requests_session() - resp = session.head(checkurl, timeout=15, - allow_redirects=True) + resp = session.head(checkurl, timeout=15, allow_redirects=True) if resp.status_code >= 300: still_pending += 1 if progress_cb: @@ -846,9 +921,15 @@ def process_queue(dryrun: bool = False, # Commit is visible — deliver the message try: smtp, fromaddr = b4.get_smtp(dryrun=dryrun) - b4.send_mail(smtp, [msg], fromaddr=fromaddr, - patatt_sign=patatt_sign, dryrun=dryrun, - output_dir=None, reflect=False) + b4.send_mail( + smtp, + [msg], + fromaddr=fromaddr, + patatt_sign=patatt_sign, + dryrun=dryrun, + output_dir=None, + reflect=False, + ) # Move to sent/ os.makedirs(sentdir, exist_ok=True) os.rename(filepath, os.path.join(sentdir, fname)) diff --git a/src/tests/conftest.py b/src/tests/conftest.py index 3ff3891..0825373 100644 --- a/src/tests/conftest.py +++ b/src/tests/conftest.py @@ -8,7 +8,7 @@ import pytest import b4 [email protected](scope="function", autouse=True) [email protected](scope='function', autouse=True) def settestdefaults(tmp_path: pathlib.Path) -> None: topdir = b4.git_get_toplevel() if topdir and topdir != os.getcwd(): @@ -25,13 +25,15 @@ def settestdefaults(tmp_path: pathlib.Path) -> None: sys._running_in_pytest = True # type: ignore[attr-defined] [email protected](scope="function") [email protected](scope='function') def sampledir(request: pytest.FixtureRequest) -> str: return os.path.join(request.path.parent, 'samples') [email protected](scope="function") -def gitdir(request: pytest.FixtureRequest, tmp_path: pathlib.Path) -> Generator[str, None, None]: [email protected](scope='function') +def gitdir( + request: pytest.FixtureRequest, tmp_path: pathlib.Path +) -> Generator[str, None, None]: sampledir = os.path.join(request.path.parent, 'samples') # look for bundle file specific to the calling fspath bname = request.path.name[5:-3] diff --git a/src/tests/test___init__.py b/src/tests/test___init__.py index faf5c96..c997059 100644 --- a/src/tests/test___init__.py +++ b/src/tests/test___init__.py @@ -11,28 +11,46 @@ import pytest import b4 [email protected]('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(sampledir: str, source: str, expected: Tuple[bool, bool, bool, Optional[str], Optional[str]]) -> None: [email protected]( + '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( + sampledir: str, + source: str, + expected: Tuple[bool, bool, bool, Optional[str], Optional[str]], +) -> None: with open(f'{sampledir}/gpg-{source}.txt', 'r') as fh: status = fh.read() assert b4.check_gpg_status(status) == expected [email protected]('source,regex,flags,ismbox', [ - (None, r'^From git@z ', 0, False), - (None, r'\n\nFrom git@z ', 0, False), - ('save-7bit-clean', r'From: Unicôdé', 0, True), - # mailbox.mbox does not properly handle 8bit-clean headers - ('save-8bit-clean', r'From: Unicôdé', 0, False), -]) -def test_save_git_am_mbox(sampledir: Optional[str], tmp_path: pathlib.Path, source: Optional[str], regex: str, flags: int, ismbox: bool) -> None: [email protected]( + 'source,regex,flags,ismbox', + [ + (None, r'^From git@z ', 0, False), + (None, r'\n\nFrom git@z ', 0, False), + ('save-7bit-clean', r'From: Unicôdé', 0, True), + # mailbox.mbox does not properly handle 8bit-clean headers + ('save-8bit-clean', r'From: Unicôdé', 0, False), + ], +) +def test_save_git_am_mbox( + sampledir: Optional[str], + tmp_path: pathlib.Path, + source: Optional[str], + regex: str, + flags: int, + ismbox: bool, +) -> None: import re + msgs: List[email.message.EmailMessage] if source is not None: if ismbox: @@ -40,11 +58,15 @@ def test_save_git_am_mbox(sampledir: Optional[str], tmp_path: pathlib.Path, sour else: import email import email.parser + with open(f'{sampledir}/{source}.txt', 'rb') as fh: - msg = email.parser.BytesParser(policy=b4.emlpolicy, _class=email.message.EmailMessage).parse(fh) + msg = email.parser.BytesParser( + policy=b4.emlpolicy, _class=email.message.EmailMessage + ).parse(fh) msgs = [msg] else: import email.message + msgs = list() for x in range(0, 3): msg = email.message.EmailMessage() @@ -73,23 +95,54 @@ def test_make_msgid_avoids_host_domain_by_default() -> None: assert _msgid_domain(b4_msgid) != socket.getfqdn() [email protected]('source,expected', [ - ('trailers-test-simple', - [('person', 'Reported-by', '"Doe, Jane" <[email protected]>', None), - ('person', 'Reviewed-by', 'Bogus Bupkes <[email protected]>', None), - ('utility', 'Fixes', 'abcdef01234567890', None), - ('utility', 'Link', 'https://msgid.link/[email protected]', None), - ]), - ('trailers-test-extinfo', - [('person', 'Reported-by', 'Some, One <[email protected]>', None), - ('person', 'Reviewed-by', 'Bogus Bupkes <[email protected]>', '[for the parts that are bogus]'), - ('utility', 'Fixes', 'abcdef01234567890', None), - ('person', 'Tested-by', 'Some Person <[email protected]>', ' [this person visually indented theirs]'), - ('utility', 'Link', 'https://msgid.link/[email protected]', ' # initial submission'), - ('person', 'Signed-off-by', 'Wrapped Persontrailer <[email protected]>', None), - ]), -]) -def test_parse_trailers(sampledir: str, source: str, expected: List[Tuple[str, str, str, Optional[str]]]) -> None: [email protected]( + 'source,expected', + [ + ( + 'trailers-test-simple', + [ + ('person', 'Reported-by', '"Doe, Jane" <[email protected]>', None), + ('person', 'Reviewed-by', 'Bogus Bupkes <[email protected]>', None), + ('utility', 'Fixes', 'abcdef01234567890', None), + ('utility', 'Link', 'https://msgid.link/[email protected]', None), + ], + ), + ( + 'trailers-test-extinfo', + [ + ('person', 'Reported-by', 'Some, One <[email protected]>', None), + ( + 'person', + 'Reviewed-by', + 'Bogus Bupkes <[email protected]>', + '[for the parts that are bogus]', + ), + ('utility', 'Fixes', 'abcdef01234567890', None), + ( + 'person', + 'Tested-by', + 'Some Person <[email protected]>', + ' [this person visually indented theirs]', + ), + ( + 'utility', + 'Link', + 'https://msgid.link/[email protected]', + ' # initial submission', + ), + ( + 'person', + 'Signed-off-by', + 'Wrapped Persontrailer <[email protected]>', + None, + ), + ], + ), + ], +) +def test_parse_trailers( + sampledir: str, source: str, expected: List[Tuple[str, str, str, Optional[str]]] +) -> None: msgs = b4.get_msgs_from_mailbox_or_maildir(f'{sampledir}/{source}.txt') for msg in msgs: lmsg = b4.LoreMessage(msg) @@ -107,76 +160,140 @@ def test_parse_trailers(sampledir: str, source: str, expected: List[Tuple[str, s assert tr.extinfo == mytr.extinfo [email protected]('name,value,exp_type,exp_addr,exp_value', [ - # Simple name - ('Signed-off-by', 'Simple Name <[email protected]>', - 'person', ('Simple Name', '[email protected]'), - 'Simple Name <[email protected]>'), - # Double quotes in display name must be preserved - ('Signed-off-by', 'Jane "JD" Doe <[email protected]>', - 'person', ('Jane "JD" Doe', '[email protected]'), - 'Jane "JD" Doe <[email protected]>'), - # Outer RFC 2822 quotes around a name with comma - ('Reported-by', '"Doe, Jane" <[email protected]>', - 'person', ('"Doe, Jane"', '[email protected]'), - '"Doe, Jane" <[email protected]>'), - # Comma in name without quotes - ('Reported-by', 'Some, One <[email protected]>', - 'person', ('Some, One', '[email protected]'), - 'Some, One <[email protected]>'), - # Parentheses in display name - ('Tested-by', 'Developer Foo (EXAMPLECORP) <[email protected]>', - 'person', ('Developer Foo (EXAMPLECORP)', '[email protected]'), - 'Developer Foo (EXAMPLECORP) <[email protected]>'), - # Bare angle-bracket email - ('Cc', '<[email protected]>', - 'person', ('', '[email protected]'), - '[email protected]'), - # Bare email without angle brackets - ('Cc', '[email protected]', - 'person', ('', '[email protected]'), - '[email protected]'), -]) -def test_trailer_addr_parsing(name: str, value: str, exp_type: str, - exp_addr: Tuple[str, str], exp_value: str) -> None: [email protected]( + 'name,value,exp_type,exp_addr,exp_value', + [ + # Simple name + ( + 'Signed-off-by', + 'Simple Name <[email protected]>', + 'person', + ('Simple Name', '[email protected]'), + 'Simple Name <[email protected]>', + ), + # Double quotes in display name must be preserved + ( + 'Signed-off-by', + 'Jane "JD" Doe <[email protected]>', + 'person', + ('Jane "JD" Doe', '[email protected]'), + 'Jane "JD" Doe <[email protected]>', + ), + # Outer RFC 2822 quotes around a name with comma + ( + 'Reported-by', + '"Doe, Jane" <[email protected]>', + 'person', + ('"Doe, Jane"', '[email protected]'), + '"Doe, Jane" <[email protected]>', + ), + # Comma in name without quotes + ( + 'Reported-by', + 'Some, One <[email protected]>', + 'person', + ('Some, One', '[email protected]'), + 'Some, One <[email protected]>', + ), + # Parentheses in display name + ( + 'Tested-by', + 'Developer Foo (EXAMPLECORP) <[email protected]>', + 'person', + ('Developer Foo (EXAMPLECORP)', '[email protected]'), + 'Developer Foo (EXAMPLECORP) <[email protected]>', + ), + # Bare angle-bracket email + ( + 'Cc', + '<[email protected]>', + 'person', + ('', '[email protected]'), + '[email protected]', + ), + # Bare email without angle brackets + ( + 'Cc', + '[email protected]', + 'person', + ('', '[email protected]'), + '[email protected]', + ), + ], +) +def test_trailer_addr_parsing( + name: str, value: str, exp_type: str, exp_addr: Tuple[str, str], exp_value: str +) -> None: tr = b4.LoreTrailer(name=name, value=value) assert tr.type == exp_type assert tr.addr == exp_addr assert tr.value == exp_value [email protected]('source,serargs,amargs,reference,b4cfg', [ - ('single', {}, {}, 'defaults', {}), - ('single', {}, {'noaddtrailers': True}, 'noadd', {}), - ('single', {}, {'addmysob': True}, 'addmysob', {}), - ('single', {}, {'addmysob': True, 'copyccs': True}, 'copyccs', {}), - ('single', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}), - ('single', {}, {'addmysob': True, 'addlink': True}, 'addmsgid', {'linktrailermask': 'Message-ID: <%s>'}), - ('single', {}, {'addmysob': True, 'copyccs': True}, 'ordered', - {'trailer-order': 'Cc,Tested*,Reviewed*,*'}), - ('single', {'sloppytrailers': True}, {'addmysob': True}, 'sloppy', {}), - ('with-cover', {}, {'addmysob': True}, 'defaults', {}), - ('with-cover', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}), - ('custody', {}, {'addmysob': True, 'copyccs': True}, 'unordered', {}), - ('custody', {}, {'addmysob': True, 'copyccs': True}, 'ordered', - {'trailer-order': 'Cc,Fixes*,Link*,Suggested*,Reviewed*,Tested*,*'}), - ('custody', {}, {'addmysob': True, 'copyccs': True}, 'with-ignored', - {'trailers-ignore-from': '[email protected]'}), - ('partial-reroll', {}, {'addmysob': True}, 'defaults', {}), - ('nore', {}, {}, 'defaults', {}), - ('non-git-patch', {}, {}, 'defaults', {}), - ('non-git-patch-with-comments', {}, {}, 'defaults', {}), - ('with-diffstat', {}, {}, 'defaults', {}), - ('name-parens', {}, {}, 'defaults', {}), - ('bare-address', {}, {}, 'defaults', {}), - ('stripped-lines', {}, {}, 'defaults', {}), - ('htmljunk', {}, {}, 'defaults', {}), -]) -def test_followup_trailers(sampledir: str, source: str, serargs: Dict[str, Any], amargs: Dict[str, Any], - reference: str, b4cfg: Dict[str, Any]) -> None: [email protected]( + 'source,serargs,amargs,reference,b4cfg', + [ + ('single', {}, {}, 'defaults', {}), + ('single', {}, {'noaddtrailers': True}, 'noadd', {}), + ('single', {}, {'addmysob': True}, 'addmysob', {}), + ('single', {}, {'addmysob': True, 'copyccs': True}, 'copyccs', {}), + ('single', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}), + ( + 'single', + {}, + {'addmysob': True, 'addlink': True}, + 'addmsgid', + {'linktrailermask': 'Message-ID: <%s>'}, + ), + ( + 'single', + {}, + {'addmysob': True, 'copyccs': True}, + 'ordered', + {'trailer-order': 'Cc,Tested*,Reviewed*,*'}, + ), + ('single', {'sloppytrailers': True}, {'addmysob': True}, 'sloppy', {}), + ('with-cover', {}, {'addmysob': True}, 'defaults', {}), + ('with-cover', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}), + ('custody', {}, {'addmysob': True, 'copyccs': True}, 'unordered', {}), + ( + 'custody', + {}, + {'addmysob': True, 'copyccs': True}, + 'ordered', + {'trailer-order': 'Cc,Fixes*,Link*,Suggested*,Reviewed*,Tested*,*'}, + ), + ( + 'custody', + {}, + {'addmysob': True, 'copyccs': True}, + 'with-ignored', + {'trailers-ignore-from': '[email protected]'}, + ), + ('partial-reroll', {}, {'addmysob': True}, 'defaults', {}), + ('nore', {}, {}, 'defaults', {}), + ('non-git-patch', {}, {}, 'defaults', {}), + ('non-git-patch-with-comments', {}, {}, 'defaults', {}), + ('with-diffstat', {}, {}, 'defaults', {}), + ('name-parens', {}, {}, 'defaults', {}), + ('bare-address', {}, {}, 'defaults', {}), + ('stripped-lines', {}, {}, 'defaults', {}), + ('htmljunk', {}, {}, 'defaults', {}), + ], +) +def test_followup_trailers( + sampledir: str, + source: str, + serargs: Dict[str, Any], + amargs: Dict[str, Any], + reference: str, + b4cfg: Dict[str, Any], +) -> None: b4.MAIN_CONFIG.update(b4cfg) lmbx = b4.LoreMailbox() - for msg in b4.get_msgs_from_mailbox_or_maildir(f'{sampledir}/trailers-followup-{source}.mbox'): + for msg in b4.get_msgs_from_mailbox_or_maildir( + f'{sampledir}/trailers-followup-{source}.mbox' + ): lmbx.add_message(msg) lser = lmbx.get_series(**serargs) assert lser is not None @@ -187,70 +304,134 @@ def test_followup_trailers(sampledir: str, source: str, serargs: Dict[str, Any], assert ifh.getvalue().decode() == fh.read() [email protected]('hval,verify,tr', [ - ('short-ascii', 'short-ascii', 'encode'), - ('short-unicôde', '=?utf-8?q?short-unic=C3=B4de?=', 'encode'), - # Long ascii - (('Lorem ipsum dolor sit amet consectetur adipiscing elit ' - 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'), - ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n' - ' eiusmod tempor incididunt ut labore et dolore magna aliqua'), 'encode'), - # Long unicode - (('Lorem îpsum dolor sit amet consectetur adipiscing elît ' - 'sed do eiusmod tempôr incididunt ut labore et dolôre magna aliqua'), - ('=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n' - ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n' - ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?='), 'encode'), - # Exactly 75 long - ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', - 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', 'encode'), - # Unicode that breaks on escape boundary - ('Lorem ipsum dolor sit amet consectetur adipiscin elît', - '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n =?utf-8?q?=C3=AEt?=', 'encode'), - # Unicode that's just 1 too long - ('Lorem ipsum dolor sit amet consectetur adipi elît', - '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n =?utf-8?q?t?=', 'encode'), - # A single address - ('[email protected]', '[email protected]', 'encode'), - # Two addresses - ('[email protected], [email protected]', '[email protected], [email protected]', 'encode'), - # Mixed addresses - ('[email protected], Foo Bar <[email protected]>', '[email protected], Foo Bar <[email protected]>', 'encode'), - # Mixed Unicode - ('[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>', - '[email protected], Foo Bar <[email protected]>, \n =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>', 'encode'), - ('[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, "Quux, Foo" <[email protected]>', - ('[email protected], Foo Bar <[email protected]>, \n' - ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, "Quux, Foo" <[email protected]>'), 'encode'), - ('01234567890123456789012345678901234567890123456789012345678901@example.org, ä <[email protected]>', - ('01234567890123456789012345678901234567890123456789012345678901@example.org, \n' - ' =?utf-8?q?=C3=A4?= <[email protected]>'), 'encode'), - # Test for https://github.com/python/cpython/issues/100900 - ('[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, "Quûx, Foo" <[email protected]>', - ('[email protected], Foo Bar <[email protected]>, \n' - ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, \n =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>'), 'encode'), - # Test preserve - ('[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, "Quûx, Foo" <[email protected]>', - '[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, \n "Quûx, Foo" <[email protected]>', - 'preserve'), - # Test decode - ('[email protected], Foo Bar <[email protected]>, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>', - '[email protected], Foo Bar <[email protected]>, \n "Quûx, Foo" <[email protected]>', - 'decode'), - # Test short message-id - ('Message-ID: <[email protected]>', '<[email protected]>', 'encode'), - # Test long message-id - ('Message-ID: <20240319-very-long-message-id-that-spans-multiple-lines-for-sure-because-longer-than-75-characters-abcde123456@longdomain.example.com>', - '<20240319-very-long-message-id-that-spans-multiple-lines-for-sure-because-longer-than-75-characters-abcde123456@longdomain.example.com>', - 'encode'), -]) -def test_header_wrapping(sampledir: str, hval: str, verify: str, tr: Literal['encode', 'decode', 'preserve']) -> None: [email protected]( + 'hval,verify,tr', + [ + ('short-ascii', 'short-ascii', 'encode'), + ('short-unicôde', '=?utf-8?q?short-unic=C3=B4de?=', 'encode'), + # Long ascii + ( + ( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit ' + 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua' + ), + ( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n' + ' eiusmod tempor incididunt ut labore et dolore magna aliqua' + ), + 'encode', + ), + # Long unicode + ( + ( + 'Lorem îpsum dolor sit amet consectetur adipiscing elît ' + 'sed do eiusmod tempôr incididunt ut labore et dolôre magna aliqua' + ), + ( + '=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n' + ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n' + ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?=' + ), + 'encode', + ), + # Exactly 75 long + ( + 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', + 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', + 'encode', + ), + # Unicode that breaks on escape boundary + ( + 'Lorem ipsum dolor sit amet consectetur adipiscin elît', + '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n =?utf-8?q?=C3=AEt?=', + 'encode', + ), + # Unicode that's just 1 too long + ( + 'Lorem ipsum dolor sit amet consectetur adipi elît', + '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n =?utf-8?q?t?=', + 'encode', + ), + # A single address + ('[email protected]', '[email protected]', 'encode'), + # Two addresses + ( + '[email protected], [email protected]', + '[email protected], [email protected]', + 'encode', + ), + # Mixed addresses + ( + '[email protected], Foo Bar <[email protected]>', + '[email protected], Foo Bar <[email protected]>', + 'encode', + ), + # Mixed Unicode + ( + '[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>', + '[email protected], Foo Bar <[email protected]>, \n =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>', + 'encode', + ), + ( + '[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, "Quux, Foo" <[email protected]>', + ( + '[email protected], Foo Bar <[email protected]>, \n' + ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, "Quux, Foo" <[email protected]>' + ), + 'encode', + ), + ( + '01234567890123456789012345678901234567890123456789012345678901@example.org, ä <[email protected]>', + ( + '01234567890123456789012345678901234567890123456789012345678901@example.org, \n' + ' =?utf-8?q?=C3=A4?= <[email protected]>' + ), + 'encode', + ), + # Test for https://github.com/python/cpython/issues/100900 + ( + '[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, "Quûx, Foo" <[email protected]>', + ( + '[email protected], Foo Bar <[email protected]>, \n' + ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, \n =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>' + ), + 'encode', + ), + # Test preserve + ( + '[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, "Quûx, Foo" <[email protected]>', + '[email protected], Foo Bar <[email protected]>, Fôo Baz <[email protected]>, \n "Quûx, Foo" <[email protected]>', + 'preserve', + ), + # Test decode + ( + '[email protected], Foo Bar <[email protected]>, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>', + '[email protected], Foo Bar <[email protected]>, \n "Quûx, Foo" <[email protected]>', + 'decode', + ), + # Test short message-id + ( + 'Message-ID: <[email protected]>', + '<[email protected]>', + 'encode', + ), + # Test long message-id + ( + 'Message-ID: <20240319-very-long-message-id-that-spans-multiple-lines-for-sure-because-longer-than-75-characters-abcde123456@longdomain.example.com>', + '<20240319-very-long-message-id-that-spans-multiple-lines-for-sure-because-longer-than-75-characters-abcde123456@longdomain.example.com>', + 'encode', + ), + ], +) +def test_header_wrapping( + sampledir: str, hval: str, verify: str, tr: Literal['encode', 'decode', 'preserve'] +) -> None: if ':' in hval: chunks = hval.split(':', maxsplit=1) hname = chunks[0].strip() hval = chunks[1].strip() else: - hname = 'To' if '@' in hval else "X-Header" + hname = 'To' if '@' in hval else 'X-Header' wrapped = b4.LoreMessage.wrap_header((hname, hval), transform=tr) assert wrapped.decode() == f'{hname}: {verify}' _wname, wval = wrapped.split(b':', maxsplit=1) @@ -259,72 +440,138 @@ def test_header_wrapping(sampledir: str, hval: str, verify: str, tr: Literal['en assert cval == hval [email protected]('pairs,verify,clean', [ - ([('', '[email protected]'), ('Foo Bar', '[email protected]')], - '[email protected], Foo Bar <[email protected]>', True), - ([('', '[email protected]'), ('Foo, Bar', '[email protected]')], - '[email protected], "Foo, Bar" <[email protected]>', True), - ([('', '[email protected]'), ('Fôo, Bar', '[email protected]')], - '[email protected], "Fôo, Bar" <[email protected]>', True), - ([('', '[email protected]'), ('=?utf-8?q?Qu=C3=BBx_Foo?=', '[email protected]')], - '[email protected], Quûx Foo <[email protected]>', True), - ([('', '[email protected]'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]')], - '[email protected], "Quûx, Foo" <[email protected]>', True), - ([('', '[email protected]'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]')], - '[email protected], =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>', False), - # Pre-quoted display name with special chars must not be double-quoted - ([('', '[email protected]'), ('"Example.org Tools"', '[email protected]')], - '[email protected], "Example.org Tools" <[email protected]>', True), - ([('', '[email protected]'), ('"Doe, Jane"', '[email protected]')], - '[email protected], "Doe, Jane" <[email protected]>', True), - # Unquoted name with internal quotes - ([('', '[email protected]'), ('Jane "JD" Doe', '[email protected]')], - '[email protected], "Jane \\"JD\\" Doe" <[email protected]>', True), - # Name starting with quote but not fully quoted - ([('', '[email protected]'), ('"JD" Doe', '[email protected]')], - '[email protected], "\\"JD\\" Doe" <[email protected]>', True), - # Pre-quoted name with internal quotes - ([('', '[email protected]'), ('"Jane "JD" Doe"', '[email protected]')], - '[email protected], "Jane \\"JD\\" Doe" <[email protected]>', True), -]) [email protected]( + 'pairs,verify,clean', + [ + ( + [('', '[email protected]'), ('Foo Bar', '[email protected]')], + '[email protected], Foo Bar <[email protected]>', + True, + ), + ( + [('', '[email protected]'), ('Foo, Bar', '[email protected]')], + '[email protected], "Foo, Bar" <[email protected]>', + True, + ), + ( + [('', '[email protected]'), ('Fôo, Bar', '[email protected]')], + '[email protected], "Fôo, Bar" <[email protected]>', + True, + ), + ( + [ + ('', '[email protected]'), + ('=?utf-8?q?Qu=C3=BBx_Foo?=', '[email protected]'), + ], + '[email protected], Quûx Foo <[email protected]>', + True, + ), + ( + [ + ('', '[email protected]'), + ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]'), + ], + '[email protected], "Quûx, Foo" <[email protected]>', + True, + ), + ( + [ + ('', '[email protected]'), + ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]'), + ], + '[email protected], =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>', + False, + ), + # Pre-quoted display name with special chars must not be double-quoted + ( + [('', '[email protected]'), ('"Example.org Tools"', '[email protected]')], + '[email protected], "Example.org Tools" <[email protected]>', + True, + ), + ( + [('', '[email protected]'), ('"Doe, Jane"', '[email protected]')], + '[email protected], "Doe, Jane" <[email protected]>', + True, + ), + # Unquoted name with internal quotes + ( + [('', '[email protected]'), ('Jane "JD" Doe', '[email protected]')], + '[email protected], "Jane \\"JD\\" Doe" <[email protected]>', + True, + ), + # Name starting with quote but not fully quoted + ( + [('', '[email protected]'), ('"JD" Doe', '[email protected]')], + '[email protected], "\\"JD\\" Doe" <[email protected]>', + True, + ), + # Pre-quoted name with internal quotes + ( + [('', '[email protected]'), ('"Jane "JD" Doe"', '[email protected]')], + '[email protected], "Jane \\"JD\\" Doe" <[email protected]>', + True, + ), + ], +) def test_format_addrs(pairs: List[Tuple[str, str]], verify: str, clean: bool) -> None: formatted = b4.format_addrs(pairs, clean) assert formatted == verify [email protected]('intrange,upper,expected', [ - ('1-3', 5, [1, 2, 3]), - ('-1', 5, [5]), - ('1,3-5', 5, [1, 3, 4, 5]), - ('1', 5, [1]), - ('3', 5, [3]), - ('5', 5, [5]), - ('1,3,4-', 6, [1, 3, 4, 5, 6]), - ('1-3,5,-1', 7, [1, 2, 3, 5, 7]), - ('-7', 5, []), - ('1-8', 3, [1, 2, 3]), -]) [email protected]( + 'intrange,upper,expected', + [ + ('1-3', 5, [1, 2, 3]), + ('-1', 5, [5]), + ('1,3-5', 5, [1, 3, 4, 5]), + ('1', 5, [1]), + ('3', 5, [3]), + ('5', 5, [5]), + ('1,3,4-', 6, [1, 3, 4, 5, 6]), + ('1-3,5,-1', 7, [1, 2, 3, 5, 7]), + ('-7', 5, []), + ('1-8', 3, [1, 2, 3]), + ], +) def test_parse_int_range(intrange: str, upper: int, expected: List[int]) -> None: assert list(b4.parse_int_range(intrange, upper)) == expected [email protected]('body_link,extra_link,expect_count', [ - # Exact same URL — should dedup to one - ('https://patch.msgid.link/[email protected]', - 'https://patch.msgid.link/[email protected]', 1), - # Same URL, different case — should still dedup - ('https://patch.msgid.link/[email protected]', - 'https://patch.msgid.link/[email protected]', 1), - # Different domains, same message-id — should dedup to one - ('https://lore.kernel.org/r/[email protected]', - 'https://patch.msgid.link/[email protected]', 1), - # URL-encoded message-id — should match decoded form - ('https://lore.kernel.org/r/20240101-test-v1-1-abc123%40example.com', - 'https://patch.msgid.link/[email protected]', 1), - # Different message-ids — both should survive - ('https://lore.kernel.org/r/[email protected]', - 'https://patch.msgid.link/[email protected]', 2), -]) [email protected]( + 'body_link,extra_link,expect_count', + [ + # Exact same URL — should dedup to one + ( + 'https://patch.msgid.link/[email protected]', + 'https://patch.msgid.link/[email protected]', + 1, + ), + # Same URL, different case — should still dedup + ( + 'https://patch.msgid.link/[email protected]', + 'https://patch.msgid.link/[email protected]', + 1, + ), + # Different domains, same message-id — should dedup to one + ( + 'https://lore.kernel.org/r/[email protected]', + 'https://patch.msgid.link/[email protected]', + 1, + ), + # URL-encoded message-id — should match decoded form + ( + 'https://lore.kernel.org/r/20240101-test-v1-1-abc123%40example.com', + 'https://patch.msgid.link/[email protected]', + 1, + ), + # Different message-ids — both should survive + ( + 'https://lore.kernel.org/r/[email protected]', + 'https://patch.msgid.link/[email protected]', + 2, + ), + ], +) def test_link_trailer_dedup(body_link: str, extra_link: str, expect_count: int) -> None: """Link: trailers already in the body should not be duplicated by extras.""" raw = ( @@ -357,9 +604,15 @@ class TestTakeFlow: """ @staticmethod - def _make_patch_msg(msgid: str, subject: str, body: str, - diff: str, counter: int = 1, expected: int = 1, - in_reply_to: Optional[str] = None) -> email.message.EmailMessage: + def _make_patch_msg( + msgid: str, + subject: str, + body: str, + diff: str, + counter: int = 1, + expected: int = 1, + in_reply_to: Optional[str] = None, + ) -> email.message.EmailMessage: """Build a realistic patch email like what lore returns. The *body* should contain the full commit message including @@ -379,19 +632,19 @@ class TestTakeFlow: if in_reply_to: raw += f'In-Reply-To: <{in_reply_to}>\n' raw += f'References: <{in_reply_to}>\n' - raw += ( - f'\n' - f'{body}\n' - f'---\n' - f'{diff}\n' - ) + raw += f'\n{body}\n---\n{diff}\n' return email.message_from_string( - raw, policy=email.policy.EmailPolicy(utf8=True)) + raw, policy=email.policy.EmailPolicy(utf8=True) + ) @staticmethod - def _make_reply_msg(msgid: str, in_reply_to: str, - from_name: str, from_email: str, - trailer_lines: List[str]) -> email.message.EmailMessage: + def _make_reply_msg( + msgid: str, + in_reply_to: str, + from_name: str, + from_email: str, + trailer_lines: List[str], + ) -> email.message.EmailMessage: """Build a followup reply with trailers.""" trailers = '\n'.join(trailer_lines) raw = ( @@ -407,7 +660,8 @@ class TestTakeFlow: f'{trailers}\n' ) return email.message_from_string( - raw, policy=email.policy.EmailPolicy(utf8=True)) + raw, policy=email.policy.EmailPolicy(utf8=True) + ) def test_link_dedup_with_followups(self, gitdir: str) -> None: """Patch already has Link: in body, get_am_ready(addlink=True) @@ -474,17 +728,16 @@ class TestTakeFlow: # Apply to master via git am ifh = io.BytesIO() b4.save_git_am_mbox(am_msgs, ifh) - ecode, out = b4.git_run_command( - gitdir, ['am'], stdin=ifh.getvalue()) + ecode, out = b4.git_run_command(gitdir, ['am'], stdin=ifh.getvalue()) assert ecode == 0, f'git am failed: {out}' - ecode, result = b4.git_run_command( - gitdir, ['log', '-1', '--format=%B']) + ecode, result = b4.git_run_command(gitdir, ['log', '-1', '--format=%B']) assert ecode == 0 # Exactly one Link: trailer, not two - assert result.count(f'Link: {link_url}') == 1, \ + assert result.count(f'Link: {link_url}') == 1, ( f'Duplicate Link: found:\n{result}' + ) # Followup trailers applied assert 'Reviewed-by: Reviewer One <[email protected]>' in result assert 'Acked-by: Acker Two <[email protected]>' in result @@ -527,12 +780,10 @@ class TestTakeFlow: ifh = io.BytesIO() b4.save_git_am_mbox(am_msgs, ifh) - ecode, out = b4.git_run_command( - gitdir, ['am'], stdin=ifh.getvalue()) + ecode, out = b4.git_run_command(gitdir, ['am'], stdin=ifh.getvalue()) assert ecode == 0, f'git am failed: {out}' - ecode, result = b4.git_run_command( - gitdir, ['log', '-1', '--format=%B']) + ecode, result = b4.git_run_command(gitdir, ['log', '-1', '--format=%B']) assert ecode == 0 expected_link = f'https://patch.msgid.link/{patch_msgid}' @@ -589,12 +840,10 @@ class TestTakeFlow: ifh = io.BytesIO() b4.save_git_am_mbox(am_msgs, ifh) - ecode, out = b4.git_run_command( - gitdir, ['am'], stdin=ifh.getvalue()) + ecode, out = b4.git_run_command(gitdir, ['am'], stdin=ifh.getvalue()) assert ecode == 0, f'git am failed: {out}' - ecode, result = b4.git_run_command( - gitdir, ['log', '-1', '--format=%B']) + ecode, result = b4.git_run_command(gitdir, ['log', '-1', '--format=%B']) assert ecode == 0 assert 'Reviewed-by: Alice Author <[email protected]>' in result @@ -643,12 +892,10 @@ class TestTakeFlow: ifh = io.BytesIO() b4.save_git_am_mbox(am_msgs, ifh) - ecode, out = b4.git_run_command( - gitdir, ['am'], stdin=ifh.getvalue()) + ecode, out = b4.git_run_command(gitdir, ['am'], stdin=ifh.getvalue()) assert ecode == 0, f'git am failed: {out}' - ecode, result = b4.git_run_command( - gitdir, ['log', '-1', '--format=%B']) + ecode, result = b4.git_run_command(gitdir, ['log', '-1', '--format=%B']) assert ecode == 0 # Same message-id in both URLs, so deduped to one Link: @@ -656,18 +903,47 @@ class TestTakeFlow: assert result.count('Link:') == 1 [email protected]('subject,extras,expected', [ - ('[PATCH] This is a patch', None, '[PATCH] This is a patch'), - ('[PATCH v3] This is a patch', None, '[PATCH v3] This is a patch'), - ('[PATCH RFC v3] This is a patch', None, '[PATCH RFC v3] This is a patch'), - ('[RFC PATCH v3 1/3] This is a patch', None, '[RFC PATCH v3 1/3] This is a patch'), - ('[RESEND PATCH v3 1/3] This is a patch', None, '[RESEND PATCH v3 1/3] This is a patch'), - ('[PATCH RFC v3 2/3] This is a patch', ['RFC'], '[PATCH RFC v3 2/3] This is a patch'), - ('[PATCH RFC v3 3/12] This is a patch', None, '[PATCH RFC v3 03/12] This is a patch'), - ('[PATCH RFC v3] This is a [patch]', ['RFC'], '[PATCH RFC v3] This is a [patch]'), - ('[PATCH RFC v3 2/3] This is a patch', ['netdev', 'bpf'], '[PATCH RFC netdev bpf v3 2/3] This is a patch'), -]) -def test_lore_subject_prefixes(subject: str, extras: Optional[List[str]], expected: str) -> None: [email protected]( + 'subject,extras,expected', + [ + ('[PATCH] This is a patch', None, '[PATCH] This is a patch'), + ('[PATCH v3] This is a patch', None, '[PATCH v3] This is a patch'), + ('[PATCH RFC v3] This is a patch', None, '[PATCH RFC v3] This is a patch'), + ( + '[RFC PATCH v3 1/3] This is a patch', + None, + '[RFC PATCH v3 1/3] This is a patch', + ), + ( + '[RESEND PATCH v3 1/3] This is a patch', + None, + '[RESEND PATCH v3 1/3] This is a patch', + ), + ( + '[PATCH RFC v3 2/3] This is a patch', + ['RFC'], + '[PATCH RFC v3 2/3] This is a patch', + ), + ( + '[PATCH RFC v3 3/12] This is a patch', + None, + '[PATCH RFC v3 03/12] This is a patch', + ), + ( + '[PATCH RFC v3] This is a [patch]', + ['RFC'], + '[PATCH RFC v3] This is a [patch]', + ), + ( + '[PATCH RFC v3 2/3] This is a patch', + ['netdev', 'bpf'], + '[PATCH RFC netdev bpf v3 2/3] This is a patch', + ), + ], +) +def test_lore_subject_prefixes( + subject: str, extras: Optional[List[str]], expected: str +) -> None: lsubj = b4.LoreSubject(subject) assert lsubj.get_rebuilt_subject(eprefixes=extras) == expected @@ -683,6 +959,7 @@ class TestGetLoreNode: from unittest.mock import MagicMock import liblore + mock_node = MagicMock() mock_from_gc = MagicMock(return_value=mock_node) monkeypatch.setattr(liblore.LoreNode, 'from_git_config', mock_from_gc) @@ -695,8 +972,11 @@ class TestGetLoreNode: from unittest.mock import MagicMock import liblore + mock_node = MagicMock() - monkeypatch.setattr(liblore.LoreNode, 'from_git_config', MagicMock(return_value=mock_node)) + monkeypatch.setattr( + liblore.LoreNode, 'from_git_config', MagicMock(return_value=mock_node) + ) b4.get_lore_node() mock_node.set_user_agent.assert_called_once_with('b4', b4.__VERSION__) @@ -705,8 +985,11 @@ class TestGetLoreNode: from unittest.mock import MagicMock import liblore + mock_node = MagicMock() - monkeypatch.setattr(liblore.LoreNode, 'from_git_config', MagicMock(return_value=mock_node)) + monkeypatch.setattr( + liblore.LoreNode, 'from_git_config', MagicMock(return_value=mock_node) + ) b4.get_lore_node() mock_node.set_requests_session.assert_not_called() @@ -715,6 +998,7 @@ class TestGetLoreNode: from unittest.mock import MagicMock import liblore + b4.MAIN_CONFIG['cache-expire'] = '5' mock_node = MagicMock() mock_from_gc = MagicMock(return_value=mock_node) @@ -729,6 +1013,7 @@ class TestGetLoreNode: from unittest.mock import MagicMock import liblore + mock_node = MagicMock() mock_from_gc = MagicMock(return_value=mock_node) monkeypatch.setattr(liblore.LoreNode, 'from_git_config', mock_from_gc) diff --git a/src/tests/test_ez.py b/src/tests/test_ez.py index ef21985..dc42cfe 100644 --- a/src/tests/test_ez.py +++ b/src/tests/test_ez.py @@ -10,33 +10,77 @@ import b4.ez import b4.mbox [email protected](scope="function") [email protected](scope='function') def prepdir(gitdir: str) -> Generator[str, None, None]: b4.MAIN_CONFIG.update({'prep-cover-strategy': 'branch-description'}) parser = b4.command.setup_parser() - b4args = ['--no-stdin', '--no-interactive', '--offline-mode', 'prep', '-n', 'pytest'] + b4args = [ + '--no-stdin', + '--no-interactive', + '--offline-mode', + 'prep', + '-n', + 'pytest', + ] cmdargs = parser.parse_args(b4args) b4.ez.cmd_prep(cmdargs) yield gitdir [email protected]('mboxf, bundlef, rep, trargs, compareargs, compareout, b4cfg', [ - ('trailers-thread-with-followups', None, None, [], - ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups', - {'shazam-am-flags': '--signoff'}), - ('trailers-thread-with-cover-followup', None, None, [], - ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-cover-followup', - {'shazam-am-flags': '--signoff'}), - # Test matching trailer updates by subject when patch-id changes - ('trailers-thread-with-followups', None, (b'vivendum', b'addendum'), [], - ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups-no-match', - {'shazam-am-flags': '--signoff'}), - # Test that we properly perserve commits with --- in them - ('trailers-thread-with-followups', 'trailers-with-tripledash', None, [], - ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups-and-tripledash', - None), -]) -def test_trailers(sampledir: str, prepdir: str, mboxf: str, bundlef: Optional[str], rep: Optional[Tuple[bytes, bytes]], trargs: List[str], compareargs: List[str], compareout: str, b4cfg: Dict[str, Any]) -> None: [email protected]( + 'mboxf, bundlef, rep, trargs, compareargs, compareout, b4cfg', + [ + ( + 'trailers-thread-with-followups', + None, + None, + [], + ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], + 'trailers-thread-with-followups', + {'shazam-am-flags': '--signoff'}, + ), + ( + 'trailers-thread-with-cover-followup', + None, + None, + [], + ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], + 'trailers-thread-with-cover-followup', + {'shazam-am-flags': '--signoff'}, + ), + # Test matching trailer updates by subject when patch-id changes + ( + 'trailers-thread-with-followups', + None, + (b'vivendum', b'addendum'), + [], + ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], + 'trailers-thread-with-followups-no-match', + {'shazam-am-flags': '--signoff'}, + ), + # Test that we properly perserve commits with --- in them + ( + 'trailers-thread-with-followups', + 'trailers-with-tripledash', + None, + [], + ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], + 'trailers-thread-with-followups-and-tripledash', + None, + ), + ], +) +def test_trailers( + sampledir: str, + prepdir: str, + mboxf: str, + bundlef: Optional[str], + rep: Optional[Tuple[bytes, bytes]], + trargs: List[str], + compareargs: List[str], + compareout: str, + b4cfg: Dict[str, Any], +) -> None: if b4cfg: b4.MAIN_CONFIG.update(b4cfg) config = b4.get_main_config() @@ -59,7 +103,15 @@ def test_trailers(sampledir: str, prepdir: str, mboxf: str, bundlef: Optional[st fh.write(contents) else: tfile = mfile - b4args = ['--no-stdin', '--no-interactive', '--offline-mode', 'shazam', '--no-add-trailers', '-m', tfile] + b4args = [ + '--no-stdin', + '--no-interactive', + '--offline-mode', + 'shazam', + '--no-add-trailers', + '-m', + tfile, + ] parser = b4.command.setup_parser() cmdargs = parser.parse_args(b4args) @@ -71,7 +123,15 @@ def test_trailers(sampledir: str, prepdir: str, mboxf: str, bundlef: Optional[st assert os.path.exists(cfile) parser = b4.command.setup_parser() - b4args = ['--no-stdin', '--no-interactive', '--offline-mode', 'trailers', '--update', '-m', mfile] + trargs + b4args = [ + '--no-stdin', + '--no-interactive', + '--offline-mode', + 'trailers', + '--update', + '-m', + mfile, + ] + trargs cmdargs = parser.parse_args(b4args) b4.ez.cmd_trailers(cmdargs) @@ -86,6 +146,7 @@ def test_trailers(sampledir: str, prepdir: str, mboxf: str, bundlef: Optional[st # Tests for pre/post-rewrite hooks # --------------------------------------------------------------------------- + class TestRunRewriteHook: """Tests for run_rewrite_hook() and its integration with run_frf().""" @@ -100,9 +161,10 @@ class TestRunRewriteHook: """A pre-hook that exits 0 should not raise.""" b4.MAIN_CONFIG['prep-pre-rewrite-hook'] = 'true' try: - with patch('b4.ez.b4._run_command', - return_value=(0, b'', b'')) as mock_run, \ - patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'): + with ( + patch('b4.ez.b4._run_command', return_value=(0, b'', b'')) as mock_run, + patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'), + ): b4.ez.run_rewrite_hook('pre') mock_run.assert_called_once_with(['true'], rundir='/tmp') finally: @@ -112,9 +174,13 @@ class TestRunRewriteHook: """A pre-hook that exits non-zero should raise RuntimeError.""" b4.MAIN_CONFIG['prep-pre-rewrite-hook'] = 'stg commit --all' try: - with patch('b4.ez.b4._run_command', - return_value=(1, b'', b'stg: not initialized\n')), \ - patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'): + with ( + patch( + 'b4.ez.b4._run_command', + return_value=(1, b'', b'stg: not initialized\n'), + ), + patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'), + ): with pytest.raises(RuntimeError, match='Pre-rewrite hook'): b4.ez.run_rewrite_hook('pre') finally: @@ -124,9 +190,10 @@ class TestRunRewriteHook: """A post-hook that exits non-zero should warn, not raise.""" b4.MAIN_CONFIG['prep-post-rewrite-hook'] = 'false' try: - with patch('b4.ez.b4._run_command', - return_value=(1, b'', b'error\n')), \ - patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'): + with ( + patch('b4.ez.b4._run_command', return_value=(1, b'', b'error\n')), + patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'), + ): # Should not raise b4.ez.run_rewrite_hook('post') finally: @@ -137,9 +204,10 @@ class TestRunRewriteHook: b4.MAIN_CONFIG['prep-pre-rewrite-hook'] = 'false' try: mock_frf = MagicMock() - with patch('b4.ez.b4._run_command', - return_value=(1, b'', b'hook failed\n')), \ - patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'): + with ( + patch('b4.ez.b4._run_command', return_value=(1, b'', b'hook failed\n')), + patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'), + ): with pytest.raises(RuntimeError): b4.ez.run_frf(mock_frf) # frf.run() should never have been called @@ -160,13 +228,14 @@ class TestRunRewriteHook: call_order.append(cmdargs[0]) return (0, b'', b'') - with patch('b4.ez.b4._run_command', side_effect=_track_run), \ - patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'), \ - patch('b4.ez.b4.git_get_gitdir', return_value='/tmp'): + with ( + patch('b4.ez.b4._run_command', side_effect=_track_run), + patch('b4.ez.b4.git_get_toplevel', return_value='/tmp'), + patch('b4.ez.b4.git_get_gitdir', return_value='/tmp'), + ): b4.ez.run_frf(mock_frf) assert call_order == ['pre-cmd', 'frf', 'post-cmd'] finally: b4.MAIN_CONFIG.pop('prep-pre-rewrite-hook', None) b4.MAIN_CONFIG.pop('prep-post-rewrite-hook', None) - diff --git a/src/tests/test_mbox.py b/src/tests/test_mbox.py index b533421..119d21d 100644 --- a/src/tests/test_mbox.py +++ b/src/tests/test_mbox.py @@ -10,28 +10,71 @@ import b4.command import b4.mbox [email protected]('mboxf, shazamargs, compareargs, compareout, b4cfg', [ - ('shazam-git1-just-series', [], - ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], 'shazam-git1-just-series-defaults', {}), - ('shazam-git1-just-series', ['-H'], - ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD..FETCH_HEAD'], 'shazam-git1-just-series-defaults', {}), - ('shazam-git1-just-series', ['-M'], - ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD^..'], 'shazam-git1-just-series-merged', {}), - # --add-link: Link: trailers are appended to each patch - ('shazam-git1-just-series', ['--add-link'], - ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], 'shazam-git1-just-series-addlink', {}), - # --add-link with pre-existing Link: in patch bodies: no duplicates - ('shazam-git1-with-link', ['--add-link'], - ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], 'shazam-git1-just-series-addlink', {}), -]) -def test_shazam(sampledir: str, gitdir: str, mboxf: str, shazamargs: List[str], compareargs: List[str], compareout: str, b4cfg: Dict[str, Any]) -> None: [email protected]( + 'mboxf, shazamargs, compareargs, compareout, b4cfg', + [ + ( + 'shazam-git1-just-series', + [], + ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], + 'shazam-git1-just-series-defaults', + {}, + ), + ( + 'shazam-git1-just-series', + ['-H'], + ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD..FETCH_HEAD'], + 'shazam-git1-just-series-defaults', + {}, + ), + ( + 'shazam-git1-just-series', + ['-M'], + ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD^..'], + 'shazam-git1-just-series-merged', + {}, + ), + # --add-link: Link: trailers are appended to each patch + ( + 'shazam-git1-just-series', + ['--add-link'], + ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], + 'shazam-git1-just-series-addlink', + {}, + ), + # --add-link with pre-existing Link: in patch bodies: no duplicates + ( + 'shazam-git1-with-link', + ['--add-link'], + ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], + 'shazam-git1-just-series-addlink', + {}, + ), + ], +) +def test_shazam( + sampledir: str, + gitdir: str, + mboxf: str, + shazamargs: List[str], + compareargs: List[str], + compareout: str, + b4cfg: Dict[str, Any], +) -> None: b4.MAIN_CONFIG.update(b4cfg) mfile = os.path.join(sampledir, f'{mboxf}.mbox') cfile = os.path.join(sampledir, f'{compareout}.verify') assert os.path.exists(mfile) assert os.path.exists(cfile) parser = b4.command.setup_parser() - shazamargs = ['--no-stdin', '--no-interactive', '--offline-mode', 'shazam', '-m', mfile] + shazamargs + shazamargs = [ + '--no-stdin', + '--no-interactive', + '--offline-mode', + 'shazam', + '-m', + mfile, + ] + shazamargs cmdargs = parser.parse_args(shazamargs) with pytest.raises(SystemExit) as e: b4.mbox.main(cmdargs) @@ -43,8 +86,9 @@ def test_shazam(sampledir: str, gitdir: str, mboxf: str, shazamargs: List[str], assert logstr == cstr -def _make_msg(subject: str, from_addr: str, date: str, - body: str = '', msgid: str = '') -> EmailMessage: +def _make_msg( + subject: str, from_addr: str, date: str, body: str = '', msgid: str = '' +) -> EmailMessage: msg = EmailMessage() msg['Subject'] = subject msg['From'] = from_addr @@ -129,10 +173,7 @@ def test_get_extra_series_accepts_matching_change_id() -> None: '[PATCH v2 0/2] foo: fix bar syntax', 'Author <[email protected]>', 'Fri, 03 Jan 2026 10:00:00 +0000', - body=( - 'v2: split into two patches.\n\n' - f'change-id: {change_id}\n' - ), + body=(f'v2: split into two patches.\n\nchange-id: {change_id}\n'), msgid='<[email protected]>', ) v2_patches = [ diff --git a/src/tests/test_messages.py b/src/tests/test_messages.py index 9d50896..d00b6ff 100644 --- a/src/tests/test_messages.py +++ b/src/tests/test_messages.py @@ -29,9 +29,7 @@ class TestGetDb: def test_sets_schema_version(self, tmp_path: pytest.TempPathFactory) -> None: conn = messages.get_db() - version = conn.execute( - 'SELECT version FROM schema_version' - ).fetchone()[0] + version = conn.execute('SELECT version FROM schema_version').fetchone()[0] assert version == messages.SCHEMA_VERSION conn.close() @@ -85,8 +83,9 @@ class TestSetFlag: def test_creates_new_row(self, tmp_path: pytest.TempPathFactory) -> None: conn = messages.get_db() - messages.set_flag(conn, '[email protected]', 'Seen', - msg_date='2026-03-05T10:00:00') + messages.set_flag( + conn, '[email protected]', 'Seen', msg_date='2026-03-05T10:00:00' + ) flags = messages.get_flags(conn, '[email protected]') assert 'Seen' in flags conn.close() @@ -197,13 +196,15 @@ class TestCleanupOld: def test_removes_old_entries(self, tmp_path: pytest.TempPathFactory) -> None: conn = messages.get_db() old_date = ( - datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(days=200) + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=200) ).isoformat() messages.set_flag(conn, '[email protected]', 'Seen', msg_date=old_date) - messages.set_flag(conn, '[email protected]', 'Seen', - msg_date=datetime.datetime.now( - datetime.timezone.utc).isoformat()) + messages.set_flag( + conn, + '[email protected]', + 'Seen', + msg_date=datetime.datetime.now(datetime.timezone.utc).isoformat(), + ) deleted = messages.cleanup_old(conn, max_days=180) assert deleted == 1 assert messages.get_flags(conn, '[email protected]') == '' diff --git a/src/tests/test_patatt.py b/src/tests/test_patatt.py index c257d41..de05277 100644 --- a/src/tests/test_patatt.py +++ b/src/tests/test_patatt.py @@ -2,6 +2,7 @@ Uses ephemeral ed25519 keys so no external key material is needed. """ + import base64 import email.message import os @@ -27,8 +28,7 @@ def ed25519_keypair() -> Generator[Tuple[str, str, str, str], None, None]: sk = SigningKey.generate() sk_b64 = base64.b64encode(sk.encode()).decode() vk_b64 = base64.b64encode(sk.verify_key.encode()).decode() - with tempfile.NamedTemporaryFile(mode='w', suffix='.key', - delete=False) as fh: + with tempfile.NamedTemporaryFile(mode='w', suffix='.key', delete=False) as fh: fh.write(sk_b64) privkey_path = fh.name yield privkey_path, vk_b64, '[email protected]', 'default' @@ -36,7 +36,9 @@ def ed25519_keypair() -> Generator[Tuple[str, str, str, str], None, None]: @pytest.fixture() -def keyring_dir(ed25519_keypair: Tuple[str, str, str, str]) -> Generator[str, None, None]: +def keyring_dir( + ed25519_keypair: Tuple[str, str, str, str], +) -> Generator[str, None, None]: """Create a temporary keyring directory with the ephemeral public key. The directory layout follows patatt's expected structure: @@ -54,9 +56,11 @@ def keyring_dir(ed25519_keypair: Tuple[str, str, str, str]) -> Generator[str, No yield tmpdir -def _make_test_message(from_addr: str = '[email protected]', - subject: str = 'Test patch', - body: str = 'This is a test.\n') -> bytes: +def _make_test_message( + from_addr: str = '[email protected]', + subject: str = 'Test patch', + body: str = 'This is a test.\n', +) -> bytes: """Build a minimal RFC2822 message as bytes.""" msg = email.message.EmailMessage() msg['From'] = from_addr @@ -70,8 +74,9 @@ def _make_test_message(from_addr: str = '[email protected]', class TestPatattSignVerify: """Round-trip sign and verify using ephemeral ed25519 keys.""" - def test_sign_and_verify(self, ed25519_keypair: Tuple[str, str, str, str], - keyring_dir: str) -> None: + def test_sign_and_verify( + self, ed25519_keypair: Tuple[str, str, str, str], keyring_dir: str + ) -> None: """A signed message should validate with the matching public key.""" privkey_path, _vk_b64, identity, selector = ed25519_keypair msg_bytes = _make_test_message(from_addr=identity) @@ -88,8 +93,9 @@ class TestPatattSignVerify: assert len(results) > 0 assert results[0][0] == patatt.RES_VALID - def test_tampered_body_fails(self, ed25519_keypair: Tuple[str, str, str, str], - keyring_dir: str) -> None: + def test_tampered_body_fails( + self, ed25519_keypair: Tuple[str, str, str, str], keyring_dir: str + ) -> None: """Modifying the body after signing should fail validation.""" privkey_path, _vk_b64, identity, selector = ed25519_keypair msg_bytes = _make_test_message(from_addr=identity) @@ -156,7 +162,9 @@ class TestPatattSignVerify: assert len(results) == 1 assert results[0][0] == patatt.RES_NOSIG - def test_sign_adds_developer_key_header(self, ed25519_keypair: Tuple[str, str, str, str]) -> None: + def test_sign_adds_developer_key_header( + self, ed25519_keypair: Tuple[str, str, str, str] + ) -> None: """Signing adds both X-Developer-Signature and X-Developer-Key.""" privkey_path, _vk_b64, identity, selector = ed25519_keypair msg_bytes = _make_test_message(from_addr=identity) diff --git a/src/tests/test_rethread.py b/src/tests/test_rethread.py index f2a0394..6e518fc 100644 --- a/src/tests/test_rethread.py +++ b/src/tests/test_rethread.py @@ -10,11 +10,15 @@ import b4 # --------------------------------------------------------------------------- # Helpers for building synthetic EmailMessage objects # --------------------------------------------------------------------------- -def _make_msg(msgid: str, subject: str, from_addr: str = 'Test Author <[email protected]>', - date: str = 'Mon, 23 Mar 2026 12:00:00 +0000', - in_reply_to: Optional[str] = None, - references: Optional[str] = None, - body: str = 'Hello\n') -> email.message.EmailMessage: +def _make_msg( + msgid: str, + subject: str, + from_addr: str = 'Test Author <[email protected]>', + date: str = 'Mon, 23 Mar 2026 12:00:00 +0000', + in_reply_to: Optional[str] = None, + references: Optional[str] = None, + body: str = 'Hello\n', +) -> email.message.EmailMessage: msg = email.message.EmailMessage() msg['Message-ID'] = f'<{msgid}>' msg['Subject'] = subject @@ -33,40 +37,50 @@ def _make_msg(msgid: str, subject: str, from_addr: str = 'Test Author <test@exam # =========================================================================== class TestParseMsgid: def test_bare_msgid(self) -> None: - assert b4.parse_msgid('[email protected]') == \ - '[email protected]' + assert ( + b4.parse_msgid('[email protected]') + == '[email protected]' + ) def test_angle_brackets(self) -> None: - assert b4.parse_msgid('<[email protected]>') == \ - '[email protected]' + assert ( + b4.parse_msgid('<[email protected]>') + == '[email protected]' + ) def test_lore_url(self) -> None: result = b4.parse_msgid( - 'https://lore.kernel.org/all/[email protected]/') + 'https://lore.kernel.org/all/[email protected]/' + ) assert result == '[email protected]' def test_lore_url_with_r_shorthand(self) -> None: result = b4.parse_msgid( - 'https://lore.kernel.org/r/[email protected]') + 'https://lore.kernel.org/r/[email protected]' + ) assert result == '[email protected]' def test_lore_url_percent_encoded(self) -> None: - result = b4.parse_msgid( - 'https://lore.kernel.org/all/abc%[email protected]/') + result = b4.parse_msgid('https://lore.kernel.org/all/abc%[email protected]/') assert result == '[email protected]' def test_patchwork_url(self) -> None: result = b4.parse_msgid( - 'https://patchwork.kernel.org/project/linux-mm/patch/[email protected]/') + 'https://patchwork.kernel.org/project/linux-mm/patch/[email protected]/' + ) assert result == '[email protected]' def test_id_prefix(self) -> None: - assert b4.parse_msgid('id:[email protected]') == \ - '[email protected]' + assert ( + b4.parse_msgid('id:[email protected]') + == '[email protected]' + ) def test_rfc822msgid_prefix(self) -> None: - assert b4.parse_msgid('rfc822msgid:[email protected]') == \ - '[email protected]' + assert ( + b4.parse_msgid('rfc822msgid:[email protected]') + == '[email protected]' + ) def test_whitespace_stripped(self) -> None: assert b4.parse_msgid(' <[email protected]> ') == '[email protected]' @@ -285,9 +299,9 @@ class TestRethreadMessages: def test_child_messages_untouched(self) -> None: cover = _make_msg('cover@x', '[PATCH 0/2] Cover') p1 = _make_msg('p1@x', '[PATCH 1/2] First') - reply = _make_msg('reply@x', 'Re: [PATCH 1/2] First', - in_reply_to='p1@x', - references='<p1@x>') + reply = _make_msg( + 'reply@x', 'Re: [PATCH 1/2] First', in_reply_to='p1@x', references='<p1@x>' + ) all_msgs = [cover, p1, reply] b4.LoreSeries.rethread_messages(all_msgs, 'cover@x', {'p1@x'}) @@ -298,9 +312,12 @@ class TestRethreadMessages: def test_strips_old_threading_from_cover(self) -> None: """If the cover had pre-existing threading, it should be stripped.""" - cover = _make_msg('cover@x', '[PATCH 0/2] Cover', - in_reply_to='old-parent@x', - references='<old-parent@x>') + cover = _make_msg( + 'cover@x', + '[PATCH 0/2] Cover', + in_reply_to='old-parent@x', + references='<old-parent@x>', + ) p1 = _make_msg('p1@x', '[PATCH 1/2] First') all_msgs = [cover, p1] @@ -312,9 +329,12 @@ class TestRethreadMessages: def test_replaces_old_threading_on_patches(self) -> None: """Patches should lose their old threading and get cover as parent.""" cover = _make_msg('cover@x', '[PATCH 0/1] Cover') - p1 = _make_msg('p1@x', '[PATCH 1/1] Fix', - in_reply_to='unrelated@x', - references='<unrelated@x> <other@x>') + p1 = _make_msg( + 'p1@x', + '[PATCH 1/1] Fix', + in_reply_to='unrelated@x', + references='<unrelated@x> <other@x>', + ) all_msgs = [cover, p1] b4.LoreSeries.rethread_messages(all_msgs, 'cover@x', {'p1@x'}) @@ -344,9 +364,9 @@ class TestRethreadMessages: # =========================================================================== class TestRethreadIntegration: @staticmethod - def _run_pipeline(msgids: List[str], - all_msgs: List[email.message.EmailMessage] - ) -> Tuple[str, b4.LoreSeries]: + def _run_pipeline( + msgids: List[str], all_msgs: List[email.message.EmailMessage] + ) -> Tuple[str, b4.LoreSeries]: """Run the rethread pipeline and feed into LoreMailbox.""" cover_msgid, all_msgs = b4.LoreSeries.rethread_series(msgids, all_msgs) @@ -359,12 +379,21 @@ class TestRethreadIntegration: def test_numbered_patches_with_cover(self) -> None: """Properly numbered patches with a cover letter should produce a complete series.""" - cover = _make_msg('cover@x', '[PATCH 0/2] Widget overhaul', - body='This series overhauls widgets.\n') - p1 = _make_msg('p1@x', '[PATCH 1/2] Refactor widget core', - body='---\n widget.c | 10 +\n 1 file changed\n\ndiff --git a/widget.c b/widget.c\n--- a/widget.c\n+++ b/widget.c\n@@ -1 +1 @@\n-old\n+new\n') - p2 = _make_msg('p2@x', '[PATCH 2/2] Add widget tests', - body='---\n test.c | 5 +\n 1 file changed\n\ndiff --git a/test.c b/test.c\n--- a/test.c\n+++ b/test.c\n@@ -1 +1 @@\n-old\n+new\n') + cover = _make_msg( + 'cover@x', + '[PATCH 0/2] Widget overhaul', + body='This series overhauls widgets.\n', + ) + p1 = _make_msg( + 'p1@x', + '[PATCH 1/2] Refactor widget core', + body='---\n widget.c | 10 +\n 1 file changed\n\ndiff --git a/widget.c b/widget.c\n--- a/widget.c\n+++ b/widget.c\n@@ -1 +1 @@\n-old\n+new\n', + ) + p2 = _make_msg( + 'p2@x', + '[PATCH 2/2] Add widget tests', + body='---\n test.c | 5 +\n 1 file changed\n\ndiff --git a/test.c b/test.c\n--- a/test.c\n+++ b/test.c\n@@ -1 +1 @@\n-old\n+new\n', + ) all_msgs = [cover, p1, p2] msgids = ['cover@x', 'p1@x', 'p2@x'] @@ -376,10 +405,16 @@ class TestRethreadIntegration: def test_unnumbered_patches_no_cover(self) -> None: """Unnumbered patches without a cover should get renumbered and threaded under patch 1.""" - p1 = _make_msg('p1@x', '[PATCH] Add alpha feature', - body='---\n a.c | 1 +\n 1 file changed\n\ndiff --git a/a.c b/a.c\n--- a/a.c\n+++ b/a.c\n@@ -1 +1 @@\n-old\n+new\n') - p2 = _make_msg('p2@x', '[PATCH] Add beta feature', - body='---\n b.c | 1 +\n 1 file changed\n\ndiff --git a/b.c b/b.c\n--- a/b.c\n+++ b/b.c\n@@ -1 +1 @@\n-old\n+new\n') + p1 = _make_msg( + 'p1@x', + '[PATCH] Add alpha feature', + body='---\n a.c | 1 +\n 1 file changed\n\ndiff --git a/a.c b/a.c\n--- a/a.c\n+++ b/a.c\n@@ -1 +1 @@\n-old\n+new\n', + ) + p2 = _make_msg( + 'p2@x', + '[PATCH] Add beta feature', + body='---\n b.c | 1 +\n 1 file changed\n\ndiff --git a/b.c b/b.c\n--- a/b.c\n+++ b/b.c\n@@ -1 +1 @@\n-old\n+new\n', + ) all_msgs = [p1, p2] msgids = ['p1@x', 'p2@x'] @@ -391,15 +426,24 @@ class TestRethreadIntegration: def test_followup_trailers_preserved(self) -> None: """Review replies should be associated with the correct patch after rethreading.""" - p1 = _make_msg('p1@x', '[PATCH 1/2] First patch', - body='---\n a.c | 1 +\n 1 file changed\n\ndiff --git a/a.c b/a.c\n--- a/a.c\n+++ b/a.c\n@@ -1 +1 @@\n-old\n+new\n') - review = _make_msg('rev@x', 'Re: [PATCH 1/2] First patch', - in_reply_to='p1@x', - references='<p1@x>', - from_addr='Reviewer <[email protected]>', - body='Looks good.\n\nReviewed-by: Reviewer <[email protected]>\n') - p2 = _make_msg('p2@x', '[PATCH 2/2] Second patch', - body='---\n b.c | 1 +\n 1 file changed\n\ndiff --git a/b.c b/b.c\n--- a/b.c\n+++ b/b.c\n@@ -1 +1 @@\n-old\n+new\n') + p1 = _make_msg( + 'p1@x', + '[PATCH 1/2] First patch', + body='---\n a.c | 1 +\n 1 file changed\n\ndiff --git a/a.c b/a.c\n--- a/a.c\n+++ b/a.c\n@@ -1 +1 @@\n-old\n+new\n', + ) + review = _make_msg( + 'rev@x', + 'Re: [PATCH 1/2] First patch', + in_reply_to='p1@x', + references='<p1@x>', + from_addr='Reviewer <[email protected]>', + body='Looks good.\n\nReviewed-by: Reviewer <[email protected]>\n', + ) + p2 = _make_msg( + 'p2@x', + '[PATCH 2/2] Second patch', + body='---\n b.c | 1 +\n 1 file changed\n\ndiff --git a/b.c b/b.c\n--- a/b.c\n+++ b/b.c\n@@ -1 +1 @@\n-old\n+new\n', + ) all_msgs = [p1, review, p2] msgids = ['p1@x', 'p2@x'] @@ -414,10 +458,16 @@ class TestRethreadIntegration: def test_wrong_expected_fixed(self) -> None: """Patches claiming 1/1 each should have expected fixed to match actual count.""" - p1 = _make_msg('p1@x', '[PATCH 1/1] First', - body='---\n a.c | 1 +\n 1 file changed\n\ndiff --git a/a.c b/a.c\n--- a/a.c\n+++ b/a.c\n@@ -1 +1 @@\n-old\n+new\n') - p2 = _make_msg('p2@x', '[PATCH 1/1] Second', - body='---\n b.c | 1 +\n 1 file changed\n\ndiff --git a/b.c b/b.c\n--- a/b.c\n+++ b/b.c\n@@ -1 +1 @@\n-old\n+new\n') + p1 = _make_msg( + 'p1@x', + '[PATCH 1/1] First', + body='---\n a.c | 1 +\n 1 file changed\n\ndiff --git a/a.c b/a.c\n--- a/a.c\n+++ b/a.c\n@@ -1 +1 @@\n-old\n+new\n', + ) + p2 = _make_msg( + 'p2@x', + '[PATCH 1/1] Second', + body='---\n b.c | 1 +\n 1 file changed\n\ndiff --git a/b.c b/b.c\n--- a/b.c\n+++ b/b.c\n@@ -1 +1 @@\n-old\n+new\n', + ) all_msgs = [p1, p2] msgids = ['p1@x', 'p2@x'] @@ -435,13 +485,17 @@ class TestRethreadIntegration: # =========================================================================== class TestDiscoverRethreadSeries: @staticmethod - def _mock_discover(seed_msg: email.message.EmailMessage, - search_results: List[email.message.EmailMessage]) -> List[str]: + def _mock_discover( + seed_msg: email.message.EmailMessage, + search_results: List[email.message.EmailMessage], + ) -> List[str]: """Run discover_rethread_series with mocked network calls.""" seed_msgid = b4.LoreMessage.get_clean_msgid(seed_msg) assert seed_msgid is not None - with mock.patch('b4.get_pi_thread_by_msgid', return_value=[seed_msg]), \ - mock.patch('b4.get_pi_search_results', return_value=search_results): + with ( + mock.patch('b4.get_pi_thread_by_msgid', return_value=[seed_msg]), + mock.patch('b4.get_pi_search_results', return_value=search_results), + ): return b4.discover_rethread_series(seed_msgid) def test_discovers_numbered_series(self) -> None: @@ -507,7 +561,9 @@ class TestDiscoverRethreadSeries: seed = _make_msg('p1@x', '[PATCH 1/2] Fix') seed_msgid = b4.LoreMessage.get_clean_msgid(seed) assert seed_msgid is not None - with mock.patch('b4.get_pi_thread_by_msgid', return_value=[seed]), \ - mock.patch('b4.get_pi_search_results', return_value=None): + with ( + mock.patch('b4.get_pi_thread_by_msgid', return_value=[seed]), + mock.patch('b4.get_pi_search_results', return_value=None), + ): result = b4.discover_rethread_series(seed_msgid) assert result == ['p1@x'] diff --git a/src/tests/test_review.py b/src/tests/test_review.py index 92ebe57..174c1b5 100644 --- a/src/tests/test_review.py +++ b/src/tests/test_review.py @@ -46,16 +46,18 @@ index 3333333..4444444 100644 """ - class TestRenderQuotedDiffWithComments: """Tests for _render_quoted_diff_with_comments().""" def test_no_comments_quotes_diff(self) -> None: """Without comments, every diff line gets a '> ' prefix.""" result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, {}, '[email protected]') + SIMPLE_DIFF, {}, '[email protected]' + ) for line in result.splitlines(): - assert line.startswith(('> ', '#')) or line == '', f'Unquoted line: {line!r}' + assert line.startswith(('> ', '#')) or line == '', ( + f'Unquoted line: {line!r}' + ) def test_own_comment_is_unquoted(self) -> None: """Own comments appear as unquoted text between quoted diff.""" @@ -63,12 +65,17 @@ class TestRenderQuotedDiffWithComments: '[email protected]': { 'name': 'Me', 'comments': [ - {'path': 'b/lib/helpers.c', 'line': 12, 'text': 'Check NULL return'}, + { + 'path': 'b/lib/helpers.c', + 'line': 12, + 'text': 'Check NULL return', + }, ], }, } result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]') + SIMPLE_DIFF, all_reviews, '[email protected]' + ) assert 'Check NULL return' in result # Comment should NOT be quoted for line in result.splitlines(): @@ -87,7 +94,8 @@ class TestRenderQuotedDiffWithComments: }, } result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]') + SIMPLE_DIFF, all_reviews, '[email protected]' + ) assert '| Looks wrong.' in result assert '| Other <[email protected]>:' in result @@ -109,7 +117,8 @@ class TestRenderQuotedDiffWithComments: }, } result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]') + SIMPLE_DIFF, all_reviews, '[email protected]' + ) assert 'My comment' in result assert '| Ext comment' in result @@ -125,15 +134,16 @@ class TestRenderQuotedDiffWithComments: }, } result = review._render_quoted_diff_with_comments( - TWO_FILE_DIFF, all_reviews, '[email protected]') + TWO_FILE_DIFF, all_reviews, '[email protected]' + ) assert 'Comment in a.c' in result assert 'Comment in b.c' in result - def test_editor_instructions_at_top(self) -> None: """Rendered output starts with # instruction lines.""" result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, {}, '[email protected]') + SIMPLE_DIFF, {}, '[email protected]' + ) lines = result.splitlines() # First non-empty line should be an instruction assert lines[0].startswith('# ') @@ -147,8 +157,11 @@ class TestRenderQuotedDiffWithComments: def test_commit_msg_quoted_before_diff(self) -> None: """Commit message body is quoted before the diff when provided.""" result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, {}, '[email protected]', - commit_msg='Subject line\n\nThis is the body.\nSecond line.') + SIMPLE_DIFF, + {}, + '[email protected]', + commit_msg='Subject line\n\nThis is the body.\nSecond line.', + ) lines = result.splitlines() # Body lines should appear quoted before the diff assert '> This is the body.' in lines @@ -169,8 +182,11 @@ class TestRenderQuotedDiffWithComments: }, } result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]', - commit_msg='Subject\n\nFirst body line.') + SIMPLE_DIFF, + all_reviews, + '[email protected]', + commit_msg='Subject\n\nFirst body line.', + ) assert 'Body comment' in result for line in result.splitlines(): if 'Body comment' in line: @@ -189,8 +205,11 @@ class TestRenderQuotedDiffWithComments: }, } result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]', - commit_msg='Subject\n\nFirst body line.') + SIMPLE_DIFF, + all_reviews, + '[email protected]', + commit_msg='Subject\n\nFirst body line.', + ) assert '| Ext msg comment' in result assert '| Other <[email protected]>:' in result assert '| via: https://lore.kernel.org/test' in result @@ -206,8 +225,11 @@ class TestRenderQuotedDiffWithComments: }, } result = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]', - commit_msg='Subject\n\nFirst body line.') + SIMPLE_DIFF, + all_reviews, + '[email protected]', + commit_msg='Subject\n\nFirst body line.', + ) lines = result.splitlines() assert 'General note' in lines note_idx = lines.index('General note') @@ -368,7 +390,8 @@ class TestQuotedEditorRoundTrip: '[email protected]': {'name': 'Me', 'comments': comments}, } rendered = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]') + SIMPLE_DIFF, all_reviews, '[email protected]' + ) extracted = review._extract_editor_comments(rendered) assert len(extracted) == 1 assert extracted[0]['path'] == 'b/lib/helpers.c' @@ -386,7 +409,8 @@ class TestQuotedEditorRoundTrip: }, } rendered = review._render_quoted_diff_with_comments( - TWO_FILE_DIFF, all_reviews, '[email protected]') + TWO_FILE_DIFF, all_reviews, '[email protected]' + ) extracted = review._extract_editor_comments(rendered) assert len(extracted) == 2 assert extracted[0]['path'] == 'b/src/a.c' @@ -401,14 +425,16 @@ class TestQuotedEditorRoundTrip: '[email protected]': {'name': 'Me', 'comments': comments}, } rendered1 = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]') + SIMPLE_DIFF, all_reviews, '[email protected]' + ) extracted1 = review._extract_editor_comments(rendered1) all_reviews2: Dict[str, Any] = { '[email protected]': {'name': 'Me', 'comments': extracted1}, } rendered2 = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews2, '[email protected]') + SIMPLE_DIFF, all_reviews2, '[email protected]' + ) extracted2 = review._extract_editor_comments(rendered2) assert len(extracted1) == len(extracted2) @@ -434,7 +460,8 @@ class TestQuotedEditorRoundTrip: }, } rendered = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]') + SIMPLE_DIFF, all_reviews, '[email protected]' + ) extracted = review._extract_editor_comments(rendered) assert len(extracted) == 1 assert extracted[0]['text'] == 'My note' @@ -446,8 +473,11 @@ class TestQuotedEditorRoundTrip: '[email protected]': {'name': 'Me', 'comments': comments}, } rendered = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]', - commit_msg='Subject\n\nFirst body line.\nSecond line.') + SIMPLE_DIFF, + all_reviews, + '[email protected]', + commit_msg='Subject\n\nFirst body line.\nSecond line.', + ) extracted = review._extract_editor_comments(rendered) msg_comments = [c for c in extracted if c['path'] == ':message'] assert len(msg_comments) == 1 @@ -461,8 +491,11 @@ class TestQuotedEditorRoundTrip: '[email protected]': {'name': 'Me', 'comments': comments}, } rendered = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]', - commit_msg='Subject\n\nBody line.') + SIMPLE_DIFF, + all_reviews, + '[email protected]', + commit_msg='Subject\n\nBody line.', + ) extracted = review._extract_editor_comments(rendered) preamble = [c for c in extracted if c['path'] == ':message' and c['line'] == 0] assert len(preamble) == 1 @@ -478,8 +511,11 @@ class TestQuotedEditorRoundTrip: '[email protected]': {'name': 'Me', 'comments': comments}, } rendered = review._render_quoted_diff_with_comments( - SIMPLE_DIFF, all_reviews, '[email protected]', - commit_msg='Subject\n\nFirst body line.') + SIMPLE_DIFF, + all_reviews, + '[email protected]', + commit_msg='Subject\n\nFirst body line.', + ) extracted = review._extract_editor_comments(rendered) msg_c = [c for c in extracted if c['path'] == ':message'] diff_c = [c for c in extracted if c['path'] != ':message'] @@ -495,11 +531,9 @@ class TestBuildReplyFromComments: def test_trailing_hunk_lines_truncated(self) -> None: """Diff lines after the last comment in a hunk are omitted.""" comments = [ - {'path': 'b/lib/helpers.c', 'line': 12, - 'text': 'Check return value.'}, + {'path': 'b/lib/helpers.c', 'line': 12, 'text': 'Check return value.'}, ] - result = review._build_reply_from_comments( - SIMPLE_DIFF, comments, []) + result = review._build_reply_from_comments(SIMPLE_DIFF, comments, []) # The comment should be present assert 'Check return value.' in result # The +kzalloc line (line 12) should be quoted @@ -513,11 +547,9 @@ class TestBuildReplyFromComments: def test_lines_before_comment_preserved(self) -> None: """Diff lines before the comment are preserved as quoted context.""" comments = [ - {'path': 'b/lib/helpers.c', 'line': 13, - 'text': 'Check field assignment.'}, + {'path': 'b/lib/helpers.c', 'line': 13, 'text': 'Check field assignment.'}, ] - result = review._build_reply_from_comments( - SIMPLE_DIFF, comments, []) + result = review._build_reply_from_comments(SIMPLE_DIFF, comments, []) # The kzalloc line (line 12) precedes the comment target assert 'kzalloc' in result # The ptr->field line (line 13) is the commented line @@ -530,8 +562,7 @@ class TestBuildReplyFromComments: {'path': 'b/lib/helpers.c', 'line': 12, 'text': 'First.'}, {'path': 'b/lib/helpers.c', 'line': 13, 'text': 'Second.'}, ] - result = review._build_reply_from_comments( - SIMPLE_DIFF, comments, []) + result = review._build_reply_from_comments(SIMPLE_DIFF, comments, []) assert 'First.' in result assert 'Second.' in result assert 'kzalloc' in result @@ -565,7 +596,8 @@ index abc..def 100644 {'path': ':message', 'line': 3, 'text': 'Comment on line three.'}, ] result = review._build_reply_from_comments( - SIMPLE_DIFF, comments, [], commit_msg=commit_msg) + SIMPLE_DIFF, comments, [], commit_msg=commit_msg + ) assert 'Comment on line three.' in result assert '> Line three.' in result @@ -576,7 +608,8 @@ index abc..def 100644 {'path': ':message', 'line': 0, 'text': 'General feedback.'}, ] result = review._build_reply_from_comments( - '', comments, [], commit_msg=commit_msg) + '', comments, [], commit_msg=commit_msg + ) lines = result.splitlines() assert 'General feedback.' in lines # Preamble should come before any quoted line @@ -592,7 +625,8 @@ index abc..def 100644 {'path': ':message', 'line': 1, 'text': 'A comment.'}, ] result = review._build_reply_from_comments( - '', comments, [], commit_msg=commit_msg) + '', comments, [], commit_msg=commit_msg + ) # Should not end with a bare > stripped = result.rstrip() assert not stripped.endswith('\n>') @@ -607,7 +641,8 @@ index abc..def 100644 {'path': ':message', 'line': 25, 'text': 'Comment here.'}, ] result = review._build_reply_from_comments( - '', comments, [], commit_msg=commit_msg) + '', comments, [], commit_msg=commit_msg + ) # Line 25 and a few lines of context above should be quoted assert '> Line 25' in result assert 'Comment here.' in result @@ -623,45 +658,47 @@ index abc..def 100644 {'path': ':message', 'line': 4, 'text': 'General comment.'}, ] result = review._build_reply_from_comments( - SIMPLE_DIFF, comments, [], commit_msg=commit_msg) + SIMPLE_DIFF, comments, [], commit_msg=commit_msg + ) assert 'General comment.' in result def test_comment_above_diff_git_roundtrips(self) -> None: """Comment above first diff --git line survives parse and render.""" commit_msg = 'Subject\n\nBody.\n\nSigned-off-by: A <[email protected]>' diff = ( - "diff --git a/f.c b/f.c\n" - "--- a/f.c\n" - "+++ b/f.c\n" - "@@ -1,3 +1,4 @@\n" - " ctx\n" - "+new\n" - " more\n" + 'diff --git a/f.c b/f.c\n' + '--- a/f.c\n' + '+++ b/f.c\n' + '@@ -1,3 +1,4 @@\n' + ' ctx\n' + '+new\n' + ' more\n' ) # Simulate what the editor would produce: quoted commit message, # separator, user comment, then quoted diff edited = ( - "> Body.\n" - ">\n" - "> Signed-off-by: A <[email protected]>\n" - ">\n" - "\n" - "My general comment.\n" - "\n" - "> diff --git a/f.c b/f.c\n" - "> --- a/f.c\n" - "> +++ b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" - "> more\n" + '> Body.\n' + '>\n' + '> Signed-off-by: A <[email protected]>\n' + '>\n' + '\n' + 'My general comment.\n' + '\n' + '> diff --git a/f.c b/f.c\n' + '> --- a/f.c\n' + '> +++ b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' + '> more\n' ) comments = review._extract_editor_comments(edited, diff_text=diff) assert len(comments) == 1 assert comments[0]['text'] == 'My general comment.' # Now rebuild the reply from those comments result = review._build_reply_from_comments( - diff, comments, [], commit_msg=commit_msg) + diff, comments, [], commit_msg=commit_msg + ) assert 'My general comment.' in result @@ -809,46 +846,60 @@ class TestBuildReviewEmailBcc: return {'trailers': ['Reviewed-by: Test <[email protected]>']} @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_bcc_set_when_present(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_bcc_set_when_present( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: series = self._make_series(bcc='[email protected]') msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['Bcc'] == '[email protected]' @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_no_bcc_when_absent(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_no_bcc_when_absent( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: series = self._make_series() msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['Bcc'] is None @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_no_bcc_when_empty(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_no_bcc_when_empty( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: series = self._make_series(bcc='') msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['Bcc'] is None @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_cc_still_works(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_cc_still_works(self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock) -> None: series = self._make_series(cc='[email protected]') msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '[email protected]' in msg['Cc'] assert '[email protected]' in msg['Cc'] @@ -856,6 +907,7 @@ class TestBuildReviewEmailBcc: # -- Tests for make_review_magic_json() -------------------------------------- + class TestMakeReviewMagicJson: """Tests for make_review_magic_json().""" @@ -879,6 +931,7 @@ class TestMakeReviewMagicJson: # -- Tests for _get_my_review() ---------------------------------------------- + class TestGetMyReview: """Tests for _get_my_review().""" @@ -912,12 +965,16 @@ class TestGetMyReview: # -- Tests for _ensure_my_review() ------------------------------------------- + class TestEnsureMyReview: """Tests for _ensure_my_review().""" def test_creates_entry_when_empty(self) -> None: target: Dict[str, Any] = {} - usercfg: Dict[str, Union[str, List[str], None]] = {'email': '[email protected]', 'name': 'User'} + usercfg: Dict[str, Union[str, List[str], None]] = { + 'email': '[email protected]', + 'name': 'User', + } entry = review._ensure_my_review(target, usercfg) assert entry['name'] == 'User' assert target['reviews']['[email protected]'] is entry @@ -925,7 +982,10 @@ class TestEnsureMyReview: def test_returns_existing_and_updates_name(self) -> None: existing = {'name': 'Old Name', 'trailers': ['Reviewed-by: Old']} target = {'reviews': {'[email protected]': existing}} - usercfg: Dict[str, Union[str, List[str], None]] = {'email': '[email protected]', 'name': 'New Name'} + usercfg: Dict[str, Union[str, List[str], None]] = { + 'email': '[email protected]', + 'name': 'New Name', + } entry = review._ensure_my_review(target, usercfg) assert entry is existing assert entry['name'] == 'New Name' @@ -940,6 +1000,7 @@ class TestEnsureMyReview: # -- Tests for _cleanup_review() --------------------------------------------- + class TestCleanupReview: """Tests for _cleanup_review().""" @@ -989,6 +1050,7 @@ class TestCleanupReview: # -- Tests for _clear_other_comments() --------------------------------------- + class TestClearOtherComments: """Tests for _clear_other_comments().""" @@ -1047,6 +1109,7 @@ class TestClearOtherComments: # -- Tests for _ensure_trailers_in_body() ------------------------------------ + class TestEnsureTrailersInBody: """Tests for _ensure_trailers_in_body().""" @@ -1085,6 +1148,7 @@ class TestEnsureTrailersInBody: # -- Tests for _build_review_email() (expanded) ------------------------------ + class TestBuildReviewEmailHeaders: """Expanded tests for _build_review_email() header and body construction.""" @@ -1112,139 +1176,191 @@ class TestBuildReviewEmailHeaders: return base @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_returns_none_when_empty_review(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_returns_none_when_empty_review( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: msg = review._build_review_email( - self._make_series(), None, {'trailers': [], 'reply': '', 'comments': []}, - 'cover', '', None) + self._make_series(), + None, + {'trailers': [], 'reply': '', 'comments': []}, + 'cover', + '', + None, + ) assert msg is None @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_returns_none_when_no_msgid(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_returns_none_when_no_msgid( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: series = self._make_series() series['header-info']['msgid'] = '' msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is None @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_subject_gets_re_prefix(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_subject_gets_re_prefix( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: msg = review._build_review_email( - self._make_series(), None, self._make_review(), 'cover', '', None) + self._make_series(), None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['Subject'] == 'Re: Test patch' @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_re_prefix_not_doubled(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_re_prefix_not_doubled( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: series = self._make_series() series['subject'] = 'Re: Already prefixed' msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['Subject'] == 'Re: Already prefixed' @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_reply_to_used_as_to(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_reply_to_used_as_to( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: series = self._make_series(**{'reply-to': '[email protected]'}) msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '[email protected]' in msg['To'] @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_from_is_series_author_when_no_reply_to(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_from_is_series_author_when_no_reply_to( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: msg = review._build_review_email( - self._make_series(), None, self._make_review(), 'cover', '', None) + self._make_series(), None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '[email protected]' in msg['To'] @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_references_without_existing(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_references_without_existing( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: msg = review._build_review_email( - self._make_series(), None, self._make_review(), 'cover', '', None) + self._make_series(), None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['References'] == '<[email protected]>' @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_references_with_existing(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_references_with_existing( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: series = self._make_series(references='<[email protected]>') msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '<[email protected]>' in msg['References'] assert '<[email protected]>' in msg['References'] @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_body_contains_trailers(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_body_contains_trailers( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: msg = review._build_review_email( - self._make_series(), None, self._make_review(), 'cover text', '', None) + self._make_series(), None, self._make_review(), 'cover text', '', None + ) assert msg is not None payload = msg.get_payload(decode=True) assert isinstance(payload, bytes) assert 'Reviewed-by: Test <[email protected]>' in payload.decode() @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_explicit_reply_text_used(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_explicit_reply_text_used( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: rev = self._make_review(reply='This is my explicit reply.') msg = review._build_review_email( - self._make_series(), None, rev, 'cover', '', None) + self._make_series(), None, rev, 'cover', '', None + ) assert msg is not None payload = msg.get_payload(decode=True) assert isinstance(payload, bytes) assert 'This is my explicit reply.' in payload.decode() @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_in_reply_to_set(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_in_reply_to_set(self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock) -> None: msg = review._build_review_email( - self._make_series(), None, self._make_review(), 'cover', '', None) + self._make_series(), None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['In-Reply-To'] == '<[email protected]>' @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_from_header_is_reviewer(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_from_header_is_reviewer( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: msg = review._build_review_email( - self._make_series(), None, self._make_review(), 'cover', '', None) + self._make_series(), None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '[email protected]' in msg['From'] assert 'Reviewer' in msg['From'] + # -- Tests for _build_review_email() user-edited To/Cc ----------------------- + class TestBuildReviewEmailToCcEdited: """Tests for user-edited To/Cc handling in _build_review_email().""" @@ -1270,73 +1386,94 @@ class TestBuildReviewEmailToCcEdited: return {'trailers': ['Reviewed-by: Test <[email protected]>']} @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_default_to_is_author(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_default_to_is_author( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: """Without tocc-edited, To should be the original author.""" msg = review._build_review_email( - self._make_series(), None, self._make_review(), 'cover', '', None) + self._make_series(), None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '[email protected]' in msg['To'] @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_default_demotes_to_header_to_cc(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_default_demotes_to_header_to_cc( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: """Without tocc-edited, original To gets folded into Cc.""" series = self._make_series(to='[email protected]') msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '[email protected]' in msg['To'] assert '[email protected]' in msg['Cc'] @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_edited_to_is_honoured(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_edited_to_is_honoured( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: """With tocc-edited, user's To choice should be used as-is.""" series = self._make_series(to='[email protected]') series['header-info']['tocc-edited'] = True msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert '[email protected]' in msg['To'] assert '[email protected]' not in (msg['To'] or '') @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_edited_cc_is_honoured(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_edited_cc_is_honoured( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: """With tocc-edited, user's Cc choice should be used as-is.""" series = self._make_series(to='[email protected]', cc='[email protected]') series['header-info']['tocc-edited'] = True msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['To'] == '[email protected]' assert msg['Cc'] == '[email protected]' @mock.patch('b4.get_email_signature', return_value='sig') - @mock.patch('b4.get_user_config', return_value={ - 'name': 'Reviewer', 'email': '[email protected]'}) - def test_edited_empty_cc_omitted(self, _mock_cfg: mock.Mock, - _mock_sig: mock.Mock) -> None: + @mock.patch( + 'b4.get_user_config', + return_value={'name': 'Reviewer', 'email': '[email protected]'}, + ) + def test_edited_empty_cc_omitted( + self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock + ) -> None: """With tocc-edited, empty Cc should not produce a Cc header.""" series = self._make_series(to='[email protected]', cc='') series['header-info']['tocc-edited'] = True msg = review._build_review_email( - series, None, self._make_review(), 'cover', '', None) + series, None, self._make_review(), 'cover', '', None + ) assert msg is not None assert msg['Cc'] is None # -- Tests for get_reference_message() --------------------------------------- + class TestGetReferenceMessage: """Tests for get_reference_message().""" @@ -1370,9 +1507,9 @@ class TestGetReferenceMessage: review.get_reference_message(lser) - # -- Tests for _collect_reply_headers() -------------------------------------- + class TestCollectReplyHeaders: """Tests for _collect_reply_headers().""" @@ -1425,6 +1562,7 @@ class TestCollectReplyHeaders: # -- Tests for _collect_followups() ------------------------------------------ + class TestCollectFollowups: """Tests for _collect_followups().""" @@ -1432,7 +1570,8 @@ class TestCollectFollowups: @staticmethod def _make_followup_trailer( - name: str, value: str, + name: str, + value: str, msgid: str = '[email protected]', fromname: str = 'Reviewer', fromemail: str = '[email protected]', @@ -1446,7 +1585,9 @@ class TestCollectFollowups: return lt def _make_lmsg( - self, body: str, followup_trailers: List[Any], + self, + body: str, + followup_trailers: List[Any], ) -> mock.Mock: """Build a mock LoreMessage with body and followup_trailers.""" lmsg = mock.Mock() @@ -1457,7 +1598,8 @@ class TestCollectFollowups: def test_basic_followup(self) -> None: """A single follow-up trailer is collected.""" ft = self._make_followup_trailer( - 'Reviewed-by', 'Reviewer <[email protected]>', + 'Reviewed-by', + 'Reviewer <[email protected]>', ) lmsg = self._make_lmsg('Some patch body\n', [ft]) result = review._collect_followups(lmsg, self.LINKMASK) @@ -1484,7 +1626,8 @@ class TestCollectFollowups: 'Signed-off-by: Author <[email protected]>\n' ) ft = self._make_followup_trailer( - 'Reviewed-by', 'Reviewer <[email protected]>', + 'Reviewed-by', + 'Reviewer <[email protected]>', ) lmsg = self._make_lmsg(body, [ft]) result = review._collect_followups(lmsg, self.LINKMASK) @@ -1492,13 +1635,10 @@ class TestCollectFollowups: def test_keeps_trailer_not_in_body(self) -> None: """Follow-up trailers NOT in the body are kept.""" - body = ( - 'Patch description\n' - '\n' - 'Signed-off-by: Author <[email protected]>\n' - ) + body = 'Patch description\n\nSigned-off-by: Author <[email protected]>\n' ft = self._make_followup_trailer( - 'Acked-by', 'Acker <[email protected]>', + 'Acked-by', + 'Acker <[email protected]>', ) lmsg = self._make_lmsg(body, [ft]) result = review._collect_followups(lmsg, self.LINKMASK) @@ -1514,11 +1654,13 @@ class TestCollectFollowups: 'Signed-off-by: Author <[email protected]>\n' ) ft_dup = self._make_followup_trailer( - 'Reviewed-by', 'Reviewer <[email protected]>', + 'Reviewed-by', + 'Reviewer <[email protected]>', msgid='[email protected]', ) ft_new = self._make_followup_trailer( - 'Tested-by', 'Tester <[email protected]>', + 'Tested-by', + 'Tester <[email protected]>', msgid='[email protected]', fromname='Tester', fromemail='[email protected]', @@ -1532,11 +1674,13 @@ class TestCollectFollowups: def test_groups_by_msgid(self) -> None: """Multiple trailers from the same reply are grouped together.""" ft1 = self._make_followup_trailer( - 'Reviewed-by', 'Reviewer <[email protected]>', + 'Reviewed-by', + 'Reviewer <[email protected]>', msgid='[email protected]', ) ft2 = self._make_followup_trailer( - 'Tested-by', 'Reviewer <[email protected]>', + 'Tested-by', + 'Reviewer <[email protected]>', msgid='[email protected]', ) lmsg = self._make_lmsg('body\n', [ft1, ft2]) @@ -1553,36 +1697,53 @@ class TestCollectFollowups: # -- Tests for _get_art_counts() --------------------------------------------- + class TestGetArtCounts: """Tests for _get_art_counts() in _tracking_app.""" @staticmethod - def _make_tracking_json(followups: Optional[List[Dict[str, Any]]] = None, patches: Optional[List[Dict[str, Any]]] = None) -> str: + def _make_tracking_json( + followups: Optional[List[Dict[str, Any]]] = None, + patches: Optional[List[Dict[str, Any]]] = None, + ) -> str: """Build a tracking commit message with the given followup data.""" tracking: Dict[str, Any] = {} if followups is not None: tracking['followups'] = followups if patches is not None: tracking['patches'] = patches - return 'Cover letter text\n\n--- b4-review-tracking ---\n' + json.dumps(tracking) + return 'Cover letter text\n\n--- b4-review-tracking ---\n' + json.dumps( + tracking + ) @mock.patch('b4.git_run_command') def test_counts_all_trailer_types(self, mock_git: mock.Mock) -> None: """Counts Acked-by, Reviewed-by, and Tested-by from followups.""" commit_msg = self._make_tracking_json( followups=[ - {'trailers': ['Acked-by: A <[email protected]>', - 'Reviewed-by: R <[email protected]>']}, + { + 'trailers': [ + 'Acked-by: A <[email protected]>', + 'Reviewed-by: R <[email protected]>', + ] + }, ], patches=[ - {'followups': [ - {'trailers': ['Tested-by: T <[email protected]>', - 'Acked-by: B <[email protected]>']}, - ]}, + { + 'followups': [ + { + 'trailers': [ + 'Tested-by: T <[email protected]>', + 'Acked-by: B <[email protected]>', + ] + }, + ] + }, ], ) mock_git.return_value = (0, commit_msg) from b4.review_tui._tracking_app import _get_art_counts + result = _get_art_counts('/tmp', 'b4/review/test') assert result == (2, 1, 1) @@ -1590,12 +1751,14 @@ class TestGetArtCounts: def test_returns_none_on_git_failure(self, mock_git: mock.Mock) -> None: mock_git.return_value = (1, '') from b4.review_tui._tracking_app import _get_art_counts + assert _get_art_counts('/tmp', 'b4/review/test') is None @mock.patch('b4.git_run_command') def test_returns_none_without_marker(self, mock_git: mock.Mock) -> None: mock_git.return_value = (0, 'Just a commit message without marker') from b4.review_tui._tracking_app import _get_art_counts + assert _get_art_counts('/tmp', 'b4/review/test') is None @mock.patch('b4.git_run_command') @@ -1603,6 +1766,7 @@ class TestGetArtCounts: commit_msg = self._make_tracking_json(patches=[{'followups': []}]) mock_git.return_value = (0, commit_msg) from b4.review_tui._tracking_app import _get_art_counts + assert _get_art_counts('/tmp', 'b4/review/test') == (0, 0, 0) @mock.patch('b4.git_run_command') @@ -1610,21 +1774,29 @@ class TestGetArtCounts: """Trailers like Signed-off-by are not counted.""" commit_msg = self._make_tracking_json( followups=[ - {'trailers': ['Signed-off-by: S <[email protected]>', - 'Reviewed-by: R <[email protected]>']}, + { + 'trailers': [ + 'Signed-off-by: S <[email protected]>', + 'Reviewed-by: R <[email protected]>', + ] + }, ], ) mock_git.return_value = (0, commit_msg) from b4.review_tui._tracking_app import _get_art_counts + assert _get_art_counts('/tmp', 'b4/review/test') == (0, 1, 0) @mock.patch('b4.git_run_command') def test_skips_comment_lines_in_json(self, mock_git: mock.Mock) -> None: """Lines starting with # in the JSON block are ignored.""" - tracking = json.dumps({'followups': [{'trailers': ['Acked-by: A <[email protected]>']}]}) + tracking = json.dumps( + {'followups': [{'trailers': ['Acked-by: A <[email protected]>']}]} + ) commit_msg = 'Cover\n\n--- b4-review-tracking ---\n# comment line\n' + tracking mock_git.return_value = (0, commit_msg) from b4.review_tui._tracking_app import _get_art_counts + assert _get_art_counts('/tmp', 'b4/review/test') == (1, 0, 0) @@ -1632,42 +1804,61 @@ class TestParseArtFromMessage: """Tests for the extracted _parse_art_from_message() helper.""" @staticmethod - def _make_msg(followups: Optional[List[Dict[str, Any]]] = None, - patches: Optional[List[Dict[str, Any]]] = None) -> str: + def _make_msg( + followups: Optional[List[Dict[str, Any]]] = None, + patches: Optional[List[Dict[str, Any]]] = None, + ) -> str: tracking: Dict[str, Any] = {} if followups is not None: tracking['followups'] = followups if patches is not None: tracking['patches'] = patches - return 'Cover letter text\n\n--- b4-review-tracking ---\n' + json.dumps(tracking) + return 'Cover letter text\n\n--- b4-review-tracking ---\n' + json.dumps( + tracking + ) def test_counts_trailers(self) -> None: from b4.review_tui._tracking_app import _parse_art_from_message + msg = self._make_msg( - followups=[{'trailers': ['Acked-by: A <[email protected]>', - 'Reviewed-by: R <[email protected]>']}], + followups=[ + { + 'trailers': [ + 'Acked-by: A <[email protected]>', + 'Reviewed-by: R <[email protected]>', + ] + } + ], patches=[{'followups': [{'trailers': ['Tested-by: T <[email protected]>']}]}], ) assert _parse_art_from_message(msg) == (1, 1, 1) def test_returns_none_without_marker(self) -> None: from b4.review_tui._tracking_app import _parse_art_from_message + assert _parse_art_from_message('no marker here') is None def test_returns_none_on_bad_json(self) -> None: from b4.review_tui._tracking_app import _parse_art_from_message - assert _parse_art_from_message('text\n\n--- b4-review-tracking ---\n{bad json') is None + + assert ( + _parse_art_from_message('text\n\n--- b4-review-tracking ---\n{bad json') + is None + ) # -- Tests for note comment stripping ---------------------------------------- + class TestNoteCommentStripping: """Tests for the # comment stripping logic used in note editing.""" @staticmethod def _strip_comments(raw_text: str) -> str: """Replicate the stripping logic from _edit_note_in_editor.""" - return '\n'.join(ln for ln in raw_text.splitlines() if not ln.startswith('#')).strip() + return '\n'.join( + ln for ln in raw_text.splitlines() if not ln.startswith('#') + ).strip() def test_strips_comment_lines(self) -> None: raw = 'This is my note\n# This is a comment\nSecond line' @@ -1700,20 +1891,26 @@ class TestNoteCommentStripping: # -- Helpers for attestation tests ------------------------------------------- + def _make_mock_attestation(status: str, identity: str, passing: bool) -> Dict[str, Any]: """Build an attestation dict as returned by LoreMessage.get_attestation_status().""" return {'status': status, 'identity': identity, 'passing': passing} -def _make_mock_lmsg(attestations: List[Dict[str, Any]], passing: bool = True, critical: bool = False) -> mock.Mock: +def _make_mock_lmsg( + attestations: List[Dict[str, Any]], passing: bool = True, critical: bool = False +) -> mock.Mock: """Build a mock LoreMessage with a canned get_attestation_status() response.""" lmsg = mock.Mock() - lmsg.get_attestation_status = mock.Mock(return_value=(attestations, passing, critical)) + lmsg.get_attestation_status = mock.Mock( + return_value=(attestations, passing, critical) + ) return lmsg # -- Tests for check_series_attestation() ------------------------------------ + class TestCheckSeriesAttestation: """Tests for check_series_attestation().""" @@ -1726,20 +1923,26 @@ class TestCheckSeriesAttestation: def test_policy_off_returns_none(self) -> None: """When attestation-policy is 'off', returns None immediately.""" lser = self._make_series([_make_mock_lmsg([])]) - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'off'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'off'} + ): assert check_series_attestation(lser) is None def test_no_signatures_returns_none_string(self) -> None: """When no attestors found on any patch, returns 'none'.""" lser = self._make_series([_make_mock_lmsg([]), _make_mock_lmsg([])]) - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'softfail'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'softfail'} + ): assert check_series_attestation(lser) == 'none' def test_single_signed_dkim(self) -> None: """A single passing DKIM attestor is reported correctly.""" att = [_make_mock_attestation('signed', 'DKIM/kernel.org', True)] lser = self._make_series([_make_mock_lmsg(att)]) - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'softfail'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'softfail'} + ): result = check_series_attestation(lser) assert result == 'signed:DKIM/kernel.org' @@ -1747,7 +1950,9 @@ class TestCheckSeriesAttestation: """A nokey attestor is reported with status 'nokey'.""" att = [_make_mock_attestation('nokey', 'ed25519/[email protected]', False)] lser = self._make_series([_make_mock_lmsg(att)]) - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'softfail'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'softfail'} + ): result = check_series_attestation(lser) assert result == 'nokey:ed25519/[email protected]' @@ -1755,7 +1960,9 @@ class TestCheckSeriesAttestation: """A badsig attestor is reported with status 'badsig'.""" att = [_make_mock_attestation('badsig', 'ed25519/[email protected]', False)] lser = self._make_series([_make_mock_lmsg(att)]) - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'softfail'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'softfail'} + ): result = check_series_attestation(lser) assert result == 'badsig:ed25519/[email protected]' @@ -1766,7 +1973,9 @@ class TestCheckSeriesAttestation: _make_mock_attestation('nokey', 'ed25519/[email protected]', False), ] lser = self._make_series([_make_mock_lmsg(att)]) - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'softfail'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'softfail'} + ): result = check_series_attestation(lser) # Sorted by (status, identity): nokey < signed alphabetically assert result is not None @@ -1779,7 +1988,9 @@ class TestCheckSeriesAttestation: """Same attestor on multiple patches is only reported once.""" att = [_make_mock_attestation('signed', 'DKIM/kernel.org', True)] lser = self._make_series([_make_mock_lmsg(att), _make_mock_lmsg(att)]) - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'softfail'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'softfail'} + ): result = check_series_attestation(lser) assert result == 'signed:DKIM/kernel.org' @@ -1788,7 +1999,9 @@ class TestCheckSeriesAttestation: att = [_make_mock_attestation('signed', 'DKIM/kernel.org', True)] lser = mock.Mock() lser.patches = [None, None, _make_mock_lmsg(att), None] - with mock.patch('b4.get_main_config', return_value={'attestation-policy': 'softfail'}): + with mock.patch( + 'b4.get_main_config', return_value={'attestation-policy': 'softfail'} + ): result = check_series_attestation(lser) assert result == 'signed:DKIM/kernel.org' @@ -1807,7 +2020,10 @@ class TestCheckSeriesAttestation: att = [_make_mock_attestation('signed', 'DKIM/kernel.org', True)] lmsg = _make_mock_lmsg(att) lser = self._make_series([lmsg]) - config = {'attestation-policy': 'softfail', 'attestation-staleness-days': 'garbage'} + config = { + 'attestation-policy': 'softfail', + 'attestation-staleness-days': 'garbage', + } with mock.patch('b4.get_main_config', return_value=config): check_series_attestation(lser) lmsg.get_attestation_status.assert_called_once_with('softfail', 0) @@ -1880,19 +2096,19 @@ class TestExtractCommentsFromQuotedReply: def test_single_hunk_single_comment(self) -> None: """A minimal single-hunk inline review produces one comment.""" inline = ( - "commit abc123\n" - "Author: Test <[email protected]>\n" - "\n" - "Test patch\n" - "\n" - "> diff --git a/fs/file.c b/fs/file.c\n" - "> @@ -10,4 +10,5 @@ void func(void)\n" - "> \tint x;\n" - "> +\tptr = malloc(sz);\n" - "\n" - "Missing NULL check after malloc.\n" - "\n" - "> \treturn 0;\n" + 'commit abc123\n' + 'Author: Test <[email protected]>\n' + '\n' + 'Test patch\n' + '\n' + '> diff --git a/fs/file.c b/fs/file.c\n' + '> @@ -10,4 +10,5 @@ void func(void)\n' + '> \tint x;\n' + '> +\tptr = malloc(sz);\n' + '\n' + 'Missing NULL check after malloc.\n' + '\n' + '> \treturn 0;\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -1903,27 +2119,27 @@ class TestExtractCommentsFromQuotedReply: def test_no_diff_produces_no_comments(self) -> None: """Text with no quoted diff content produces nothing.""" - inline = "commit abc123\nAuthor: Test\n\nJust text, no diffs.\n" + inline = 'commit abc123\nAuthor: Test\n\nJust text, no diffs.\n' comments = review._extract_comments_from_quoted_reply(inline) assert comments == [] def test_truncation_markers_skipped(self) -> None: """'[ ... ]' markers don't appear in comment text.""" inline = ( - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" - "\n" - "Comment here.\n" - "\n" - "[ ... ]\n" - "\n" - "> @@ -10,3 +10,4 @@\n" - "> ctx2\n" - "> +new2\n" - "\n" - "Another comment.\n" + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' + '\n' + 'Comment here.\n' + '\n' + '[ ... ]\n' + '\n' + '> @@ -10,3 +10,4 @@\n' + '> ctx2\n' + '> +new2\n' + '\n' + 'Another comment.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 2 @@ -1934,15 +2150,15 @@ class TestExtractCommentsFromQuotedReply: def test_multiline_comment(self) -> None: """Multiple non-quoted lines between diff sections form one comment.""" inline = ( - "> diff --git a/f.c b/f.c\n" - "> @@ -5,3 +5,4 @@ void f(void)\n" - "> \tint a;\n" - "> +\tint b;\n" - "\n" - "This variable name is confusing.\n" - "Consider using a more descriptive name.\n" - "\n" - "> \treturn;\n" + '> diff --git a/f.c b/f.c\n' + '> @@ -5,3 +5,4 @@ void f(void)\n' + '> \tint a;\n' + '> +\tint b;\n' + '\n' + 'This variable name is confusing.\n' + 'Consider using a more descriptive name.\n' + '\n' + '> \treturn;\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -1952,19 +2168,19 @@ class TestExtractCommentsFromQuotedReply: def test_multi_paragraph_comment_stays_merged(self) -> None: """Two paragraphs separated by a blank line become one comment.""" inline = ( - "> diff --git a/f.c b/f.c\n" - "> --- a/f.c\n" - "> +++ b/f.c\n" - "> @@ -5,3 +5,5 @@ void f(void)\n" - "> \tint a;\n" - "> +\tint b;\n" - "> +\tint c;\n" - "\n" - "First paragraph of review.\n" - "\n" - "Second paragraph of review.\n" - "\n" - "> \treturn;\n" + '> diff --git a/f.c b/f.c\n' + '> --- a/f.c\n' + '> +++ b/f.c\n' + '> @@ -5,3 +5,5 @@ void f(void)\n' + '> \tint a;\n' + '> +\tint b;\n' + '> +\tint c;\n' + '\n' + 'First paragraph of review.\n' + '\n' + 'Second paragraph of review.\n' + '\n' + '> \treturn;\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -1974,23 +2190,23 @@ class TestExtractCommentsFromQuotedReply: def test_comments_in_different_hunks_stay_separate(self) -> None: """Comments in different hunks (far apart) stay separate.""" inline = ( - "> diff --git a/f.c b/f.c\n" - "> --- a/f.c\n" - "> +++ b/f.c\n" - "> @@ -5,3 +5,4 @@\n" - "> \tint a;\n" - "> +\tint b;\n" - "\n" - "Comment on hunk 1.\n" - "\n" - "> \treturn;\n" - "> @@ -100,3 +101,4 @@\n" - "> \tvoid x;\n" - "> +\tvoid y;\n" - "\n" - "Comment on hunk 2.\n" - "\n" - "> \treturn;\n" + '> diff --git a/f.c b/f.c\n' + '> --- a/f.c\n' + '> +++ b/f.c\n' + '> @@ -5,3 +5,4 @@\n' + '> \tint a;\n' + '> +\tint b;\n' + '\n' + 'Comment on hunk 1.\n' + '\n' + '> \treturn;\n' + '> @@ -100,3 +101,4 @@\n' + '> \tvoid x;\n' + '> +\tvoid y;\n' + '\n' + 'Comment on hunk 2.\n' + '\n' + '> \treturn;\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 2 @@ -2000,18 +2216,18 @@ class TestExtractCommentsFromQuotedReply: def test_email_reply_with_file_headers(self) -> None: """Email follow-ups include --- a/ and +++ b/ lines; parser handles them.""" email_reply = ( - "On Mon, Jan 1, 2024, Dev <[email protected]> wrote:\n" - "> diff --git a/fs/file.c b/fs/file.c\n" - "> index abc123..def456 100644\n" - "> --- a/fs/file.c\n" - "> +++ b/fs/file.c\n" - "> @@ -10,3 +10,4 @@ void f(void)\n" - "> \tint x;\n" - "> +\tptr = malloc(sz);\n" - "\n" - "Missing NULL check.\n" - "\n" - "> \treturn 0;\n" + 'On Mon, Jan 1, 2024, Dev <[email protected]> wrote:\n' + '> diff --git a/fs/file.c b/fs/file.c\n' + '> index abc123..def456 100644\n' + '> --- a/fs/file.c\n' + '> +++ b/fs/file.c\n' + '> @@ -10,3 +10,4 @@ void f(void)\n' + '> \tint x;\n' + '> +\tptr = malloc(sz);\n' + '\n' + 'Missing NULL check.\n' + '\n' + '> \treturn 0;\n' ) comments = review._extract_comments_from_quoted_reply(email_reply) assert len(comments) == 1 @@ -2022,12 +2238,7 @@ class TestExtractCommentsFromQuotedReply: def test_bare_gt_prefix(self) -> None: """Lines starting with just '>' (no space) are also parsed.""" inline = ( - ">diff --git a/f.c b/f.c\n" - ">@@ -1,3 +1,4 @@\n" - "> ctx\n" - ">+new\n" - "\n" - "Looks good.\n" + '>diff --git a/f.c b/f.c\n>@@ -1,3 +1,4 @@\n> ctx\n>+new\n\nLooks good.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2036,19 +2247,19 @@ class TestExtractCommentsFromQuotedReply: def test_comments_in_different_files(self) -> None: """Comments in different files produce separate entries with correct paths.""" inline = ( - "> diff --git a/a.c b/a.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new_a\n" - "\n" - "Comment in a.c.\n" - "\n" - "> diff --git a/b.c b/b.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new_b\n" - "\n" - "Comment in b.c.\n" + '> diff --git a/a.c b/a.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new_a\n' + '\n' + 'Comment in a.c.\n' + '\n' + '> diff --git a/b.c b/b.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new_b\n' + '\n' + 'Comment in b.c.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 2 @@ -2060,14 +2271,14 @@ class TestExtractCommentsFromQuotedReply: def test_preamble_before_diff_ignored(self) -> None: """Text before the first quoted diff line is not treated as a comment.""" inline = ( - "Hi, some general feedback below:\n" - "\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" - "\n" - "Actual inline comment.\n" + 'Hi, some general feedback below:\n' + '\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' + '\n' + 'Actual inline comment.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2076,12 +2287,12 @@ class TestExtractCommentsFromQuotedReply: def test_trailing_comment_flushed(self) -> None: """A comment at the very end (no trailing quoted line) is still captured.""" inline = ( - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" - "\n" - "Final comment with no trailing diff.\n" + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' + '\n' + 'Final comment with no trailing diff.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2090,14 +2301,14 @@ class TestExtractCommentsFromQuotedReply: def test_deletion_line_anchors_to_a_file(self) -> None: """Comment after a deletion line anchors to the a-side file and line.""" inline = ( - "> diff --git a/old.c b/old.c\n" - "> @@ -10,4 +10,3 @@\n" - "> ctx\n" - "> -removed_line\n" - "\n" - "Why was this removed?\n" - "\n" - "> more ctx\n" + '> diff --git a/old.c b/old.c\n' + '> @@ -10,4 +10,3 @@\n' + '> ctx\n' + '> -removed_line\n' + '\n' + 'Why was this removed?\n' + '\n' + '> more ctx\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2105,19 +2316,18 @@ class TestExtractCommentsFromQuotedReply: # Deletion at a_line=11, so comment anchors to line 11 assert comments[0]['line'] == 11 - def test_commit_message_comment_extracted(self) -> None: """Comments on quoted commit message lines get :message path.""" inline = ( - "> This is the commit body.\n" - "> It explains the change.\n" - "\n" - "Why is this needed?\n" - "\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" + '> This is the commit body.\n' + '> It explains the change.\n' + '\n' + 'Why is this needed?\n' + '\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2128,17 +2338,18 @@ class TestExtractCommentsFromQuotedReply: def test_preamble_captured_when_enabled(self) -> None: """With capture_preamble=True, text before first quote is a comment.""" inline = ( - "General feedback on this patch.\n" - "\n" - "> Commit body line.\n" - "\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" + 'General feedback on this patch.\n' + '\n' + '> Commit body line.\n' + '\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' ) comments = review._extract_comments_from_quoted_reply( - inline, capture_preamble=True) + inline, capture_preamble=True + ) preamble = [c for c in comments if c['line'] == 0] assert len(preamble) == 1 assert preamble[0]['path'] == ':message' @@ -2147,14 +2358,14 @@ class TestExtractCommentsFromQuotedReply: def test_preamble_not_captured_by_default(self) -> None: """Without capture_preamble, text before first quote is ignored.""" inline = ( - "General feedback on this patch.\n" - "\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" - "\n" - "Actual comment.\n" + 'General feedback on this patch.\n' + '\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' + '\n' + 'Actual comment.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2163,18 +2374,19 @@ class TestExtractCommentsFromQuotedReply: def test_attribution_line_skipped_in_preamble(self) -> None: """The 'On ..., ... wrote:' attribution line is not captured.""" inline = ( - "On Thu, 12 Mar 2026 15:54:20 +0100, Author <[email protected]> wrote:\n" - "> Commit body.\n" - "\n" - "My comment.\n" - "\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" + 'On Thu, 12 Mar 2026 15:54:20 +0100, Author <[email protected]> wrote:\n' + '> Commit body.\n' + '\n' + 'My comment.\n' + '\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' ) comments = review._extract_comments_from_quoted_reply( - inline, capture_preamble=True) + inline, capture_preamble=True + ) # Attribution line should NOT become a comment for c in comments: assert 'wrote:' not in c.get('text', '') @@ -2182,11 +2394,7 @@ class TestExtractCommentsFromQuotedReply: def test_orphan_hunk_header_enters_diff_mode(self) -> None: """A @@ hunk header without diff --git still enters diff mode.""" inline = ( - "> @@ -10,3 +10,4 @@ some_func\n" - "> ctx\n" - "> +new line\n" - "\n" - "This needs a test.\n" + '> @@ -10,3 +10,4 @@ some_func\n> ctx\n> +new line\n\nThis needs a test.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2197,13 +2405,13 @@ class TestExtractCommentsFromQuotedReply: def test_orphan_file_headers_enter_diff_mode(self) -> None: """--- a/ and +++ b/ without diff --git still enter diff mode.""" inline = ( - "> --- a/kernel/sched.c\n" - "> +++ b/kernel/sched.c\n" - "> @@ -5,3 +5,4 @@\n" - "> existing\n" - "> +added\n" - "\n" - "Why this change?\n" + '> --- a/kernel/sched.c\n' + '> +++ b/kernel/sched.c\n' + '> @@ -5,3 +5,4 @@\n' + '> existing\n' + '> +added\n' + '\n' + 'Why this change?\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2214,11 +2422,7 @@ class TestExtractCommentsFromQuotedReply: def test_trimmed_diff_with_content_resolution(self) -> None: """Trimmed reply resolved against real diff gets correct position.""" # User trimmed everything except the line they're commenting on - inline = ( - "> +new line\n" - "\n" - "Looks good.\n" - ) + inline = '> +new line\n\nLooks good.\n' comments = review._extract_comments_from_quoted_reply(inline) # Comment is captured (even without file path from headers) assert len(comments) == 1 @@ -2227,13 +2431,13 @@ class TestExtractCommentsFromQuotedReply: # Now resolve against the real diff real_diff = ( - "diff --git a/f.c b/f.c\n" - "--- a/f.c\n" - "+++ b/f.c\n" - "@@ -1,3 +1,4 @@\n" - " ctx\n" - "+new line\n" - " more\n" + 'diff --git a/f.c b/f.c\n' + '--- a/f.c\n' + '+++ b/f.c\n' + '@@ -1,3 +1,4 @@\n' + ' ctx\n' + '+new line\n' + ' more\n' ) review._resolve_comment_positions(real_diff, comments) assert comments[0]['path'] == 'b/f.c' @@ -2243,15 +2447,15 @@ class TestExtractCommentsFromQuotedReply: """A diff --git line wrapped by the editor is rejoined.""" # Editor wraps at 72 chars, splitting diff --git into two lines inline = ( - "> diff --git a/tools/lib/python/kdoc/xforms_lists.py\n" - "b/tools/lib/python/kdoc/xforms_lists.py\n" - "> --- a/tools/lib/python/kdoc/xforms_lists.py\n" - "> +++ b/tools/lib/python/kdoc/xforms_lists.py\n" - "> @@ -4,7 +4,8 @@\n" - "> existing\n" - "> +from kdoc.c_lex import CMatch\n" - "\n" - "Only editing 2nd file.\n" + '> diff --git a/tools/lib/python/kdoc/xforms_lists.py\n' + 'b/tools/lib/python/kdoc/xforms_lists.py\n' + '> --- a/tools/lib/python/kdoc/xforms_lists.py\n' + '> +++ b/tools/lib/python/kdoc/xforms_lists.py\n' + '> @@ -4,7 +4,8 @@\n' + '> existing\n' + '> +from kdoc.c_lex import CMatch\n' + '\n' + 'Only editing 2nd file.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2262,15 +2466,15 @@ class TestExtractCommentsFromQuotedReply: def test_wrapped_diff_git_line_quoted_continuation(self) -> None: """A diff --git line wrapped with quoted continuation is rejoined.""" inline = ( - "> diff --git a/tools/lib/python/kdoc/xforms_lists.py\n" - "> b/tools/lib/python/kdoc/xforms_lists.py\n" - "> --- a/tools/lib/python/kdoc/xforms_lists.py\n" - "> +++ b/tools/lib/python/kdoc/xforms_lists.py\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new\n" - "\n" - "Comment here.\n" + '> diff --git a/tools/lib/python/kdoc/xforms_lists.py\n' + '> b/tools/lib/python/kdoc/xforms_lists.py\n' + '> --- a/tools/lib/python/kdoc/xforms_lists.py\n' + '> +++ b/tools/lib/python/kdoc/xforms_lists.py\n' + '> @@ -1,3 +1,4 @@\n' + '> ctx\n' + '> +new\n' + '\n' + 'Comment here.\n' ) comments = review._extract_comments_from_quoted_reply(inline) assert len(comments) == 1 @@ -2280,21 +2484,16 @@ class TestExtractCommentsFromQuotedReply: def test_extract_editor_comments_with_diff_resolution(self) -> None: """_extract_editor_comments resolves positions when diff provided.""" edited = ( - "# instructions\n" - "> @@ -1,3 +1,4 @@\n" - "> ctx\n" - "> +new line\n" - "\n" - "My comment.\n" + '# instructions\n> @@ -1,3 +1,4 @@\n> ctx\n> +new line\n\nMy comment.\n' ) real_diff = ( - "diff --git a/f.c b/f.c\n" - "--- a/f.c\n" - "+++ b/f.c\n" - "@@ -1,3 +1,4 @@\n" - " ctx\n" - "+new line\n" - " more\n" + 'diff --git a/f.c b/f.c\n' + '--- a/f.c\n' + '+++ b/f.c\n' + '@@ -1,3 +1,4 @@\n' + ' ctx\n' + '+new line\n' + ' more\n' ) comments = review._extract_editor_comments(edited, diff_text=real_diff) assert len(comments) == 1 @@ -2347,20 +2546,24 @@ class TestResolveCommentPositions: # Sashiko uses fake context hunks even for new files, so the # content key has a space prefix while the real diff has + prefix. real_diff = ( - "diff --git a/f.c b/f.c\n" - "new file mode 100644\n" - "--- /dev/null\n" - "+++ b/f.c\n" - "@@ -0,0 +1,5 @@\n" - "+int x;\n" - "+int y;\n" - "+return -EINVAL;\n" - "+if (check)\n" - "+\treturn 0;\n" + 'diff --git a/f.c b/f.c\n' + 'new file mode 100644\n' + '--- /dev/null\n' + '+++ b/f.c\n' + '@@ -0,0 +1,5 @@\n' + '+int x;\n' + '+int y;\n' + '+return -EINVAL;\n' + '+if (check)\n' + '+\treturn 0;\n' ) comments = [ - {'path': 'f.c', 'line': 90, 'text': 'Bug here.', - 'content': ' return -EINVAL;'}, + { + 'path': 'f.c', + 'line': 90, + 'text': 'Bug here.', + 'content': ' return -EINVAL;', + }, ] review._resolve_comment_positions(real_diff, comments) assert comments[0]['line'] == 3 @@ -2369,24 +2572,23 @@ class TestResolveCommentPositions: def test_exact_prefix_match_still_works(self) -> None: """Content with matching prefix (both +) still resolves correctly.""" real_diff = ( - "diff --git a/f.c b/f.c\n" - "--- a/f.c\n" - "+++ b/f.c\n" - "@@ -10,3 +10,4 @@\n" - " ctx\n" - "+new_line\n" - " more\n" + 'diff --git a/f.c b/f.c\n' + '--- a/f.c\n' + '+++ b/f.c\n' + '@@ -10,3 +10,4 @@\n' + ' ctx\n' + '+new_line\n' + ' more\n' ) comments = [ - {'path': 'f.c', 'line': 99, 'text': 'Review.', - 'content': '+new_line'}, + {'path': 'f.c', 'line': 99, 'text': 'Review.', 'content': '+new_line'}, ] review._resolve_comment_positions(real_diff, comments) assert comments[0]['line'] == 11 def test_no_content_key_keeps_original_position(self) -> None: """Comments without content key are not touched.""" - real_diff = "diff --git a/f.c b/f.c\n--- a/f.c\n+++ b/f.c\n@@ -1,1 +1,1 @@\n-old\n+new\n" + real_diff = 'diff --git a/f.c b/f.c\n--- a/f.c\n+++ b/f.c\n@@ -1,1 +1,1 @@\n-old\n+new\n' comments = [{'path': 'f.c', 'line': 42, 'text': 'Note.'}] review._resolve_comment_positions(real_diff, comments) assert comments[0]['line'] == 42 @@ -2395,22 +2597,26 @@ class TestResolveCommentPositions: """When the same line appears multiple times, pick the closest match.""" # Simulates a new file with return -EINVAL; at lines 10, 30, and 50 real_diff = ( - "diff --git a/f.c b/f.c\n" - "new file mode 100644\n" - "--- /dev/null\n" - "+++ b/f.c\n" - "@@ -0,0 +1,50 @@\n" - + "".join(f"+line{i}\n" for i in range(1, 10)) - + "+\treturn -EINVAL;\n" # line 10 - + "".join(f"+line{i}\n" for i in range(11, 30)) - + "+\treturn -EINVAL;\n" # line 30 - + "".join(f"+line{i}\n" for i in range(31, 50)) - + "+\treturn -EINVAL;\n" # line 50 + 'diff --git a/f.c b/f.c\n' + 'new file mode 100644\n' + '--- /dev/null\n' + '+++ b/f.c\n' + '@@ -0,0 +1,50 @@\n' + + ''.join(f'+line{i}\n' for i in range(1, 10)) + + '+\treturn -EINVAL;\n' # line 10 + + ''.join(f'+line{i}\n' for i in range(11, 30)) + + '+\treturn -EINVAL;\n' # line 30 + + ''.join(f'+line{i}\n' for i in range(31, 50)) + + '+\treturn -EINVAL;\n' # line 50 ) # Sashiko says line 30 with context-prefix content comments = [ - {'path': 'f.c', 'line': 30, 'text': 'Bug here.', - 'content': ' \treturn -EINVAL;'}, + { + 'path': 'f.c', + 'line': 30, + 'text': 'Bug here.', + 'content': ' \treturn -EINVAL;', + }, ] review._resolve_comment_positions(real_diff, comments) # Should pick line 30 (closest to source position 30) @@ -2436,17 +2642,17 @@ class TestIntegrateSashikoReviews: 'status': 'Reviewed', 'output': '{}', 'inline_review': ( - "commit aaa\n" - "Author: Test\n\n" - "Test patch 1\n\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -10,3 +10,4 @@ void f(void)\n" - "> \tint x;\n" - "> +\tptr = alloc();\n" - "\n" - "Missing error check.\n" - "\n" - "> \treturn 0;\n" + 'commit aaa\n' + 'Author: Test\n\n' + 'Test patch 1\n\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -10,3 +10,4 @@ void f(void)\n' + '> \tint x;\n' + '> +\tptr = alloc();\n' + '\n' + 'Missing error check.\n' + '\n' + '> \treturn 0;\n' ), }, { @@ -2463,26 +2669,33 @@ class TestIntegrateSashikoReviews: """When sashiko-url is not configured, returns False immediately.""" with mock.patch('b4.get_main_config', return_value={}): result = review._integrate_sashiko_reviews( - '/tmp', '', {'series': {}, 'patches': []}, [], []) + '/tmp', '', {'series': {}, 'patches': []}, [], [] + ) assert result is False def test_no_series_msgid_returns_false(self) -> None: """When series has no message_id, returns False.""" - with mock.patch('b4.get_main_config', - return_value={'sashiko-url': 'https://sashiko.dev'}): + with mock.patch( + 'b4.get_main_config', return_value={'sashiko-url': 'https://sashiko.dev'} + ): result = review._integrate_sashiko_reviews( - '/tmp', '', {'series': {}, 'patches': []}, [], []) + '/tmp', '', {'series': {}, 'patches': []}, [], [] + ) assert result is False def test_api_returns_none(self) -> None: """When sashiko API returns nothing, returns False.""" series = {'message_id': '[email protected]'} - with mock.patch('b4.get_main_config', - return_value={'sashiko-url': 'https://sashiko.dev'}): - with mock.patch('b4.review.checks._fetch_sashiko_patchset', return_value=None): + with mock.patch( + 'b4.get_main_config', return_value={'sashiko-url': 'https://sashiko.dev'} + ): + with mock.patch( + 'b4.review.checks._fetch_sashiko_patchset', return_value=None + ): with mock.patch('b4.review.checks.clear_sashiko_cache'): result = review._integrate_sashiko_reviews( - '/tmp', '', {'series': series, 'patches': []}, [], []) + '/tmp', '', {'series': series, 'patches': []}, [], [] + ) assert result is False def test_integrates_inline_comments(self) -> None: @@ -2496,26 +2709,34 @@ class TestIntegrateSashikoReviews: commit_shas = ['aaaa', 'bbbb'] # Real diff matching the inline review structure real_diff = ( - "diff --git a/f.c b/f.c\n" - "index 111..222 100644\n" - "--- a/f.c\n" - "+++ b/f.c\n" - "@@ -10,3 +10,4 @@ void f(void)\n" - " \tint x;\n" - "+\tptr = alloc();\n" - " \treturn 0;\n" - ) - with mock.patch('b4.get_main_config', - return_value={'sashiko-url': 'https://sashiko.dev'}): - with mock.patch('b4.review.checks._fetch_sashiko_patchset', - return_value=self._SASHIKO_RESPONSE): + 'diff --git a/f.c b/f.c\n' + 'index 111..222 100644\n' + '--- a/f.c\n' + '+++ b/f.c\n' + '@@ -10,3 +10,4 @@ void f(void)\n' + ' \tint x;\n' + '+\tptr = alloc();\n' + ' \treturn 0;\n' + ) + with mock.patch( + 'b4.get_main_config', return_value={'sashiko-url': 'https://sashiko.dev'} + ): + with mock.patch( + 'b4.review.checks._fetch_sashiko_patchset', + return_value=self._SASHIKO_RESPONSE, + ): with mock.patch('b4.review.checks.clear_sashiko_cache'): with mock.patch('b4.git_run_command') as mock_git: mock_git.return_value = (0, real_diff) with mock.patch.object(review, 'save_tracking_ref'): result = review._integrate_sashiko_reviews( - '/tmp', 'cover', tracking, commit_shas, patches, - branch='b4/review/test') + '/tmp', + 'cover', + tracking, + commit_shas, + patches, + branch='b4/review/test', + ) assert result is True # Patch 1 should have sashiko comments @@ -2535,26 +2756,31 @@ class TestIntegrateSashikoReviews: ] series = {'message_id': '[email protected]'} tracking = {'series': series, 'patches': patches} - with mock.patch('b4.get_main_config', - return_value={'sashiko-url': 'https://sashiko.dev'}): - with mock.patch('b4.review.checks._fetch_sashiko_patchset', - return_value=self._SASHIKO_RESPONSE): + with mock.patch( + 'b4.get_main_config', return_value={'sashiko-url': 'https://sashiko.dev'} + ): + with mock.patch( + 'b4.review.checks._fetch_sashiko_patchset', + return_value=self._SASHIKO_RESPONSE, + ): with mock.patch('b4.review.checks.clear_sashiko_cache'): result = review._integrate_sashiko_reviews( - '/tmp', '', tracking, ['aaa'], patches) + '/tmp', '', tracking, ['aaa'], patches + ) assert result is False def test_uses_header_info_msgid_fallback(self) -> None: """Falls back to header-info.msgid when message_id is missing.""" series = {'header-info': {'msgid': '[email protected]'}} tracking = {'series': series, 'patches': []} - with mock.patch('b4.get_main_config', - return_value={'sashiko-url': 'https://sashiko.dev'}): - with mock.patch('b4.review.checks._fetch_sashiko_patchset', - return_value=None) as mock_fetch: + with mock.patch( + 'b4.get_main_config', return_value={'sashiko-url': 'https://sashiko.dev'} + ): + with mock.patch( + 'b4.review.checks._fetch_sashiko_patchset', return_value=None + ) as mock_fetch: with mock.patch('b4.review.checks.clear_sashiko_cache'): - review._integrate_sashiko_reviews( - '/tmp', '', tracking, [], []) + review._integrate_sashiko_reviews('/tmp', '', tracking, [], []) # Should have been called with the header-info msgid mock_fetch.assert_called_once_with('[email protected]', 'https://sashiko.dev') @@ -2573,10 +2799,10 @@ class TestIntegrateSashikoReviews: 'patch_id': 100, 'status': 'Reviewed', 'inline_review': ( - "commit aaa\nAuthor: Test\n\nOld\n\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n> ctx\n> +new\n" - "\nOld review comment.\n" + 'commit aaa\nAuthor: Test\n\nOld\n\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n> ctx\n> +new\n' + '\nOld review comment.\n' ), }, { @@ -2584,31 +2810,40 @@ class TestIntegrateSashikoReviews: 'patch_id': 100, 'status': 'Reviewed', 'inline_review': ( - "commit bbb\nAuthor: Test\n\nNew\n\n" - "> diff --git a/f.c b/f.c\n" - "> @@ -1,3 +1,4 @@\n> ctx\n> +new\n" - "\nNew review comment.\n" + 'commit bbb\nAuthor: Test\n\nNew\n\n' + '> diff --git a/f.c b/f.c\n' + '> @@ -1,3 +1,4 @@\n> ctx\n> +new\n' + '\nNew review comment.\n' ), }, ], } - patches: List[Dict[str, Any]] = [{'header-info': {'msgid': '[email protected]'}}] + patches: List[Dict[str, Any]] = [ + {'header-info': {'msgid': '[email protected]'}} + ] series = {'message_id': '[email protected]'} tracking = {'series': series, 'patches': patches} real_diff = ( - "diff --git a/f.c b/f.c\n--- a/f.c\n+++ b/f.c\n" - "@@ -1,3 +1,4 @@\n ctx\n+new\n ctx\n" + 'diff --git a/f.c b/f.c\n--- a/f.c\n+++ b/f.c\n' + '@@ -1,3 +1,4 @@\n ctx\n+new\n ctx\n' ) - with mock.patch('b4.get_main_config', - return_value={'sashiko-url': 'https://sashiko.dev'}): - with mock.patch('b4.review.checks._fetch_sashiko_patchset', - return_value=patchset): + with mock.patch( + 'b4.get_main_config', return_value={'sashiko-url': 'https://sashiko.dev'} + ): + with mock.patch( + 'b4.review.checks._fetch_sashiko_patchset', return_value=patchset + ): with mock.patch('b4.review.checks.clear_sashiko_cache'): with mock.patch('b4.git_run_command', return_value=(0, real_diff)): with mock.patch.object(review, 'save_tracking_ref'): review._integrate_sashiko_reviews( - '/tmp', '', tracking, ['aaa'], patches, - branch='b4/review/test') + '/tmp', + '', + tracking, + ['aaa'], + patches, + branch='b4/review/test', + ) comments = patches[0]['reviews']['[email protected]']['comments'] # Should have the newer review's comment assert any('New review comment' in c['text'] for c in comments) @@ -2624,66 +2859,79 @@ class TestIntegrateSashikoReviews: '[email protected]': { 'name': 'sashiko.dev', 'sashiko-review-id': 200, - 'comments': [{'path': 'f.c', 'line': 11, 'text': 'Already here.'}], + 'comments': [ + {'path': 'f.c', 'line': 11, 'text': 'Already here.'} + ], }, }, }, ] series = {'message_id': '[email protected]'} tracking = {'series': series, 'patches': patches} - with mock.patch('b4.get_main_config', - return_value={'sashiko-url': 'https://sashiko.dev'}): - with mock.patch('b4.review.checks._fetch_sashiko_patchset', - return_value=self._SASHIKO_RESPONSE): + with mock.patch( + 'b4.get_main_config', return_value={'sashiko-url': 'https://sashiko.dev'} + ): + with mock.patch( + 'b4.review.checks._fetch_sashiko_patchset', + return_value=self._SASHIKO_RESPONSE, + ): with mock.patch('b4.review.checks.clear_sashiko_cache'): with mock.patch('b4.git_run_command') as mock_git: result = review._integrate_sashiko_reviews( - '/tmp', '', tracking, ['aaaa'], patches) + '/tmp', '', tracking, ['aaaa'], patches + ) # Should not have called git diff (skipped re-parsing) mock_git.assert_not_called() assert result is False # Original comments untouched - assert patches[0]['reviews']['[email protected]']['comments'][0]['text'] == 'Already here.' + assert ( + patches[0]['reviews']['[email protected]']['comments'][0]['text'] + == 'Already here.' + ) class TestIntegrateFollowupInlineComments: """Tests for _integrate_followup_inline_comments().""" _FOLLOWUP_BODY_WITH_DIFF = ( - "On Mon, Jan 1, 2024, Dev <[email protected]> wrote:\n" - "> diff --git a/fs/file.c b/fs/file.c\n" - "> index abc123..def456 100644\n" - "> --- a/fs/file.c\n" - "> +++ b/fs/file.c\n" - "> @@ -10,3 +10,4 @@ void f(void)\n" - "> \tint x;\n" - "> +\tptr = malloc(sz);\n" - "\n" - "Missing NULL check after malloc.\n" - "\n" - "> \treturn 0;\n" + 'On Mon, Jan 1, 2024, Dev <[email protected]> wrote:\n' + '> diff --git a/fs/file.c b/fs/file.c\n' + '> index abc123..def456 100644\n' + '> --- a/fs/file.c\n' + '> +++ b/fs/file.c\n' + '> @@ -10,3 +10,4 @@ void f(void)\n' + '> \tint x;\n' + '> +\tptr = malloc(sz);\n' + '\n' + 'Missing NULL check after malloc.\n' + '\n' + '> \treturn 0;\n' ) _FOLLOWUP_BODY_NO_DIFF = ( - "I think this approach makes sense, but can we also\n" - "add a test for the error path?\n" + 'I think this approach makes sense, but can we also\n' + 'add a test for the error path?\n' ) - def _make_followup_comments(self, bodies_by_patch: Dict[int, List[str]]) -> Dict[int, List[Dict[str, Any]]]: + def _make_followup_comments( + self, bodies_by_patch: Dict[int, List[str]] + ) -> Dict[int, List[Dict[str, Any]]]: """Build a followup_comments dict like _parse_msgs_to_followup_comments returns.""" result: Dict[int, List[Dict[str, Any]]] = {} for display_idx, body_list in bodies_by_patch.items(): entries = [] for i, body in enumerate(body_list): - entries.append({ - 'body': body, - 'fromname': f'Reviewer {i}', - 'fromemail': f'reviewer{i}@example.com', - 'date': '2024-01-01', - 'msgid': f'followup{display_idx}-{i}@example.com', - 'subject': 'Re: [PATCH]', - 'depth': 0, - }) + entries.append( + { + 'body': body, + 'fromname': f'Reviewer {i}', + 'fromemail': f'reviewer{i}@example.com', + 'date': '2024-01-01', + 'msgid': f'followup{display_idx}-{i}@example.com', + 'subject': 'Re: [PATCH]', + 'depth': 0, + } + ) result[display_idx] = entries return result @@ -2691,7 +2939,8 @@ class TestIntegrateFollowupInlineComments: """Without a thread-blob, returns False immediately.""" tracking: Dict[str, Any] = {'series': {}, 'patches': []} result = review._integrate_followup_inline_comments( - '/tmp', '', tracking, [], []) + '/tmp', '', tracking, [], [] + ) assert result is False def test_extracts_inline_comments_from_followup(self) -> None: @@ -2707,30 +2956,39 @@ class TestIntegrateFollowupInlineComments: commit_shas = ['aaaa'] # Follow-up body that quotes diff with a comment - followup_comments = self._make_followup_comments({ - 1: [self._FOLLOWUP_BODY_WITH_DIFF], # display_idx 1 = patch 0 - }) + followup_comments = self._make_followup_comments( + { + 1: [self._FOLLOWUP_BODY_WITH_DIFF], # display_idx 1 = patch 0 + } + ) real_diff = ( - "diff --git a/fs/file.c b/fs/file.c\n" - "index abc123..def456 100644\n" - "--- a/fs/file.c\n" - "+++ b/fs/file.c\n" - "@@ -10,3 +10,4 @@ void f(void)\n" - " \tint x;\n" - "+\tptr = malloc(sz);\n" - " \treturn 0;\n" + 'diff --git a/fs/file.c b/fs/file.c\n' + 'index abc123..def456 100644\n' + '--- a/fs/file.c\n' + '+++ b/fs/file.c\n' + '@@ -10,3 +10,4 @@ void f(void)\n' + ' \tint x;\n' + '+\tptr = malloc(sz);\n' + ' \treturn 0;\n' ) with mock.patch('b4.review.tracking.get_thread_mbox', return_value=b'mbox'): with mock.patch('liblore.utils.split_mbox', return_value=[]): - with mock.patch('b4.review.tracking._parse_msgs_to_followup_comments', - return_value=followup_comments): + with mock.patch( + 'b4.review.tracking._parse_msgs_to_followup_comments', + return_value=followup_comments, + ): with mock.patch('b4.git_run_command', return_value=(0, real_diff)): with mock.patch.object(review, 'save_tracking_ref'): result = review._integrate_followup_inline_comments( - '/tmp', 'cover', tracking, commit_shas, patches, - branch='b4/review/test') + '/tmp', + 'cover', + tracking, + commit_shas, + patches, + branch='b4/review/test', + ) assert result is True assert 'reviews' in patches[0] @@ -2750,16 +3008,21 @@ class TestIntegrateFollowupInlineComments: 'thread-blob': 'abc123', } tracking = {'series': series, 'patches': patches} - followup_comments = self._make_followup_comments({ - 1: [self._FOLLOWUP_BODY_NO_DIFF], - }) + followup_comments = self._make_followup_comments( + { + 1: [self._FOLLOWUP_BODY_NO_DIFF], + } + ) with mock.patch('b4.review.tracking.get_thread_mbox', return_value=b'mbox'): with mock.patch('liblore.utils.split_mbox', return_value=[]): - with mock.patch('b4.review.tracking._parse_msgs_to_followup_comments', - return_value=followup_comments): + with mock.patch( + 'b4.review.tracking._parse_msgs_to_followup_comments', + return_value=followup_comments, + ): result = review._integrate_followup_inline_comments( - '/tmp', '', tracking, ['aaa'], patches) + '/tmp', '', tracking, ['aaa'], patches + ) assert result is False assert 'reviews' not in patches[0] @@ -2773,16 +3036,21 @@ class TestIntegrateFollowupInlineComments: 'thread-blob': 'abc123', } tracking = {'series': series, 'patches': patches} - followup_comments = self._make_followup_comments({ - 0: [self._FOLLOWUP_BODY_WITH_DIFF], # cover letter - }) + followup_comments = self._make_followup_comments( + { + 0: [self._FOLLOWUP_BODY_WITH_DIFF], # cover letter + } + ) with mock.patch('b4.review.tracking.get_thread_mbox', return_value=b'mbox'): with mock.patch('liblore.utils.split_mbox', return_value=[]): - with mock.patch('b4.review.tracking._parse_msgs_to_followup_comments', - return_value=followup_comments): + with mock.patch( + 'b4.review.tracking._parse_msgs_to_followup_comments', + return_value=followup_comments, + ): result = review._integrate_followup_inline_comments( - '/tmp', '', tracking, ['aaa'], patches) + '/tmp', '', tracking, ['aaa'], patches + ) assert result is False def test_multiple_reviewers_same_patch(self) -> None: @@ -2795,30 +3063,39 @@ class TestIntegrateFollowupInlineComments: 'thread-blob': 'abc123', } tracking = {'series': series, 'patches': patches} - followup_comments = self._make_followup_comments({ - 1: [self._FOLLOWUP_BODY_WITH_DIFF, self._FOLLOWUP_BODY_WITH_DIFF], - }) + followup_comments = self._make_followup_comments( + { + 1: [self._FOLLOWUP_BODY_WITH_DIFF, self._FOLLOWUP_BODY_WITH_DIFF], + } + ) real_diff = ( - "diff --git a/fs/file.c b/fs/file.c\n" - "index abc123..def456 100644\n" - "--- a/fs/file.c\n" - "+++ b/fs/file.c\n" - "@@ -10,3 +10,4 @@ void f(void)\n" - " \tint x;\n" - "+\tptr = malloc(sz);\n" - " \treturn 0;\n" + 'diff --git a/fs/file.c b/fs/file.c\n' + 'index abc123..def456 100644\n' + '--- a/fs/file.c\n' + '+++ b/fs/file.c\n' + '@@ -10,3 +10,4 @@ void f(void)\n' + ' \tint x;\n' + '+\tptr = malloc(sz);\n' + ' \treturn 0;\n' ) with mock.patch('b4.review.tracking.get_thread_mbox', return_value=b'mbox'): with mock.patch('liblore.utils.split_mbox', return_value=[]): - with mock.patch('b4.review.tracking._parse_msgs_to_followup_comments', - return_value=followup_comments): + with mock.patch( + 'b4.review.tracking._parse_msgs_to_followup_comments', + return_value=followup_comments, + ): with mock.patch('b4.git_run_command', return_value=(0, real_diff)): with mock.patch.object(review, 'save_tracking_ref'): result = review._integrate_followup_inline_comments( - '/tmp', 'cover', tracking, ['aaa'], patches, - branch='b4/review/test') + '/tmp', + 'cover', + tracking, + ['aaa'], + patches, + branch='b4/review/test', + ) assert result is True reviews = patches[0]['reviews'] @@ -2835,7 +3112,9 @@ class TestIntegrateFollowupInlineComments: '[email protected]': { 'name': 'Reviewer 0', 'followup-msgid': '[email protected]', - 'comments': [{'path': 'fs/file.c', 'line': 11, 'text': 'Already here.'}], + 'comments': [ + {'path': 'fs/file.c', 'line': 11, 'text': 'Already here.'} + ], }, }, }, @@ -2845,22 +3124,30 @@ class TestIntegrateFollowupInlineComments: 'thread-blob': 'abc123', } tracking = {'series': series, 'patches': patches} - followup_comments = self._make_followup_comments({ - 1: [self._FOLLOWUP_BODY_WITH_DIFF], - }) + followup_comments = self._make_followup_comments( + { + 1: [self._FOLLOWUP_BODY_WITH_DIFF], + } + ) with mock.patch('b4.review.tracking.get_thread_mbox', return_value=b'mbox'): with mock.patch('liblore.utils.split_mbox', return_value=[]): - with mock.patch('b4.review.tracking._parse_msgs_to_followup_comments', - return_value=followup_comments): + with mock.patch( + 'b4.review.tracking._parse_msgs_to_followup_comments', + return_value=followup_comments, + ): with mock.patch('b4.git_run_command') as mock_git: result = review._integrate_followup_inline_comments( - '/tmp', '', tracking, ['aaa'], patches) + '/tmp', '', tracking, ['aaa'], patches + ) # Should not have called git diff (skipped re-parsing) mock_git.assert_not_called() assert result is False # Original comments untouched - assert patches[0]['reviews']['[email protected]']['comments'][0]['text'] == 'Already here.' + assert ( + patches[0]['reviews']['[email protected]']['comments'][0]['text'] + == 'Already here.' + ) class TestFollowupItemPerMessage: @@ -2888,6 +3175,7 @@ class TestFollowupItemPerMessage: def test_followup_item_keyed_by_msgid(self) -> None: """FollowupItem stores msgid, not fromemail.""" from b4.review_tui._review_app import FollowupItem + item = FollowupItem('Alice', 1, '[email protected]') assert item.msgid == '[email protected]' assert item.display_idx == 1 @@ -2895,6 +3183,7 @@ class TestFollowupItemPerMessage: def test_selected_followup_enables_reply_in_preview(self) -> None: """check_action returns True for edit_reply when a follow-up is selected.""" from b4.review_tui._review_app import ReviewApp + app = ReviewApp(self._make_session()) app._preview_mode = True app._selected_followup_msgid = '[email protected]' @@ -2903,6 +3192,7 @@ class TestFollowupItemPerMessage: def test_selected_followup_cleared_on_show_content(self) -> None: """_selected_followup_msgid is reset when switching patches.""" from b4.review_tui._review_app import ReviewApp + app = ReviewApp(self._make_session()) app._selected_followup_msgid = '[email protected]' # Verify it was set @@ -2935,8 +3225,9 @@ index aaa..bbb 100644 """ -def _make_patch_msg(subject: str, from_addr: str, date: str, - body: str = '', msgid: str = '') -> email.message.EmailMessage: +def _make_patch_msg( + subject: str, from_addr: str, date: str, body: str = '', msgid: str = '' +) -> email.message.EmailMessage: """Build a minimal EmailMessage that LoreMailbox can parse as a patch.""" msg = email.message.EmailMessage() msg['Subject'] = subject @@ -3017,6 +3308,7 @@ class TestGetLoreSeriesVersionMismatch: # -- Tests for collect_review_emails() ---------------------------------------- + class TestCollectReviewEmails: """Tests for collect_review_emails() filtering logic. @@ -3059,60 +3351,65 @@ class TestCollectReviewEmails: # Use a sentinel email message so we can count how many were produced. _FAKE_MSG = mock.sentinel.email_msg - @mock.patch('b4.review._review._build_review_email', - return_value=_FAKE_MSG) - @mock.patch('b4.get_user_config', - return_value={'name': 'Maintainer', 'email': MY_EMAIL}) - def test_sends_normal_cover_review(self, _cfg: mock.Mock, - _build: mock.Mock) -> None: + @mock.patch('b4.review._review._build_review_email', return_value=_FAKE_MSG) + @mock.patch( + 'b4.get_user_config', return_value={'name': 'Maintainer', 'email': MY_EMAIL} + ) + def test_sends_normal_cover_review( + self, _cfg: mock.Mock, _build: mock.Mock + ) -> None: """A cover review without sent-revision produces one email.""" series = self._make_series({self.MY_EMAIL: self._review()}) msgs = review.collect_review_emails(series, [], 'cover', '', []) assert len(msgs) == 1 - @mock.patch('b4.review._review._build_review_email', - return_value=_FAKE_MSG) - @mock.patch('b4.get_user_config', - return_value={'name': 'Maintainer', 'email': MY_EMAIL}) - def test_skips_cover_with_sent_revision(self, _cfg: mock.Mock, - _build: mock.Mock) -> None: + @mock.patch('b4.review._review._build_review_email', return_value=_FAKE_MSG) + @mock.patch( + 'b4.get_user_config', return_value={'name': 'Maintainer', 'email': MY_EMAIL} + ) + def test_skips_cover_with_sent_revision( + self, _cfg: mock.Mock, _build: mock.Mock + ) -> None: """Cover review stamped with sent-revision is not re-sent.""" series = self._make_series( - {self.MY_EMAIL: self._review(**{'sent-revision': 1})}) + {self.MY_EMAIL: self._review(**{'sent-revision': 1})} + ) msgs = review.collect_review_emails(series, [], 'cover', '', []) assert msgs == [] - @mock.patch('b4.review._review._build_review_email', - return_value=_FAKE_MSG) - @mock.patch('b4.get_user_config', - return_value={'name': 'Maintainer', 'email': MY_EMAIL}) - def test_sends_normal_patch_review(self, _cfg: mock.Mock, - _build: mock.Mock) -> None: + @mock.patch('b4.review._review._build_review_email', return_value=_FAKE_MSG) + @mock.patch( + 'b4.get_user_config', return_value={'name': 'Maintainer', 'email': MY_EMAIL} + ) + def test_sends_normal_patch_review( + self, _cfg: mock.Mock, _build: mock.Mock + ) -> None: """A patch review without sent-revision produces one email.""" series = self._make_series() patch = self._make_patch({self.MY_EMAIL: self._review()}) msgs = review.collect_review_emails(series, [patch], 'cover', '', ['sha1']) assert len(msgs) == 1 - @mock.patch('b4.review._review._build_review_email', - return_value=_FAKE_MSG) - @mock.patch('b4.get_user_config', - return_value={'name': 'Maintainer', 'email': MY_EMAIL}) - def test_skips_patch_with_sent_revision(self, _cfg: mock.Mock, - _build: mock.Mock) -> None: + @mock.patch('b4.review._review._build_review_email', return_value=_FAKE_MSG) + @mock.patch( + 'b4.get_user_config', return_value={'name': 'Maintainer', 'email': MY_EMAIL} + ) + def test_skips_patch_with_sent_revision( + self, _cfg: mock.Mock, _build: mock.Mock + ) -> None: """Patch review stamped with sent-revision is not re-sent.""" series = self._make_series() - patch = self._make_patch( - {self.MY_EMAIL: self._review(**{'sent-revision': 1})}) + patch = self._make_patch({self.MY_EMAIL: self._review(**{'sent-revision': 1})}) msgs = review.collect_review_emails(series, [patch], 'cover', '', ['sha1']) assert msgs == [] - @mock.patch('b4.review._review._build_review_email', - return_value=_FAKE_MSG) - @mock.patch('b4.get_user_config', - return_value={'name': 'Maintainer', 'email': MY_EMAIL}) - def test_skips_patch_auto_skipped_after_upgrade(self, _cfg: mock.Mock, - _build: mock.Mock) -> None: + @mock.patch('b4.review._review._build_review_email', return_value=_FAKE_MSG) + @mock.patch( + 'b4.get_user_config', return_value={'name': 'Maintainer', 'email': MY_EMAIL} + ) + def test_skips_patch_auto_skipped_after_upgrade( + self, _cfg: mock.Mock, _build: mock.Mock + ) -> None: """Patch auto-marked skip+skip-reason during upgrade is not re-sent. This is the combo A+B fix: the upgrade step sets patch-state=skip @@ -3121,38 +3418,49 @@ class TestCollectReviewEmails: prevent re-sending; this test exercises the skip-state path. """ series = self._make_series() - patch = self._make_patch({self.MY_EMAIL: self._review( - **{'sent-revision': 1, - 'patch-state': 'skip', - 'skip-reason': 'Patch unchanged from v1; review already sent'})}) + patch = self._make_patch( + { + self.MY_EMAIL: self._review( + **{ + 'sent-revision': 1, + 'patch-state': 'skip', + 'skip-reason': 'Patch unchanged from v1; review already sent', + } + ) + } + ) msgs = review.collect_review_emails(series, [patch], 'cover', '', ['sha1']) assert msgs == [] - @mock.patch('b4.review._review._build_review_email', - return_value=_FAKE_MSG) - @mock.patch('b4.get_user_config', - return_value={'name': 'Maintainer', 'email': MY_EMAIL}) - def test_only_unsent_patches_included(self, _cfg: mock.Mock, - _build: mock.Mock) -> None: + @mock.patch('b4.review._review._build_review_email', return_value=_FAKE_MSG) + @mock.patch( + 'b4.get_user_config', return_value={'name': 'Maintainer', 'email': MY_EMAIL} + ) + def test_only_unsent_patches_included( + self, _cfg: mock.Mock, _build: mock.Mock + ) -> None: """Mix of sent and unsent patches: only unsent ones produce emails.""" series = self._make_series() sent_patch = self._make_patch( - {self.MY_EMAIL: self._review(**{'sent-revision': 1})}) - fresh_patch = self._make_patch( - {self.MY_EMAIL: self._review()}) + {self.MY_EMAIL: self._review(**{'sent-revision': 1})} + ) + fresh_patch = self._make_patch({self.MY_EMAIL: self._review()}) msgs = review.collect_review_emails( - series, [sent_patch, fresh_patch], 'cover', '', ['sha1', 'sha2']) + series, [sent_patch, fresh_patch], 'cover', '', ['sha1', 'sha2'] + ) assert len(msgs) == 1 - @mock.patch('b4.review._review._build_review_email', - return_value=_FAKE_MSG) - @mock.patch('b4.get_user_config', - return_value={'name': 'Maintainer', 'email': MY_EMAIL}) + @mock.patch('b4.review._review._build_review_email', return_value=_FAKE_MSG) + @mock.patch( + 'b4.get_user_config', return_value={'name': 'Maintainer', 'email': MY_EMAIL} + ) def test_skip_state_without_sent_revision_still_skipped( - self, _cfg: mock.Mock, _build: mock.Mock) -> None: + self, _cfg: mock.Mock, _build: mock.Mock + ) -> None: """Explicit skip state (manually set, no sent-revision) is honoured.""" series = self._make_series() patch = self._make_patch( - {self.MY_EMAIL: self._review(**{'patch-state': 'skip'})}) + {self.MY_EMAIL: self._review(**{'patch-state': 'skip'})} + ) msgs = review.collect_review_emails(series, [patch], 'cover', '', ['sha1']) assert msgs == [] diff --git a/src/tests/test_review_checks.py b/src/tests/test_review_checks.py index c866082..1469bf3 100644 --- a/src/tests/test_review_checks.py +++ b/src/tests/test_review_checks.py @@ -13,8 +13,10 @@ from b4.review import checks # Helpers # --------------------------------------------------------------------------- -def _make_msg(subject: str = 'test patch', msgid: str = '[email protected]', - body: str = 'dummy') -> EmailMessage: + +def _make_msg( + subject: str = 'test patch', msgid: str = '[email protected]', body: str = 'dummy' +) -> EmailMessage: """Create a minimal EmailMessage for testing.""" msg = EmailMessage() msg['Subject'] = subject @@ -27,6 +29,7 @@ def _make_msg(subject: str = 'test patch', msgid: str = '[email protected]', # SQLite cache: store / retrieve / delete / cleanup # --------------------------------------------------------------------------- + class TestCacheDb: """Tests for the CI check cache database.""" @@ -43,10 +46,20 @@ class TestCacheDb: def test_store_and_retrieve(self, tmp_path: pytest.TempPathFactory) -> None: conn = checks.get_db() results = [ - {'tool': 'lint', 'status': 'pass', 'summary': 'ok', - 'url': '', 'details': ''}, - {'tool': 'build', 'status': 'fail', 'summary': 'broken', - 'url': 'https://ci.example.com', 'details': 'error on line 5'}, + { + 'tool': 'lint', + 'status': 'pass', + 'summary': 'ok', + 'url': '', + 'details': '', + }, + { + 'tool': 'build', + 'status': 'fail', + 'summary': 'broken', + 'url': 'https://ci.example.com', + 'details': 'error on line 5', + }, ] checks.store_results(conn, 'msg1@example', results) cached = checks.get_cached_results(conn, ['msg1@example']) @@ -70,10 +83,12 @@ class TestCacheDb: def test_store_replaces_existing(self, tmp_path: pytest.TempPathFactory) -> None: conn = checks.get_db() - checks.store_results(conn, 'msg@ex', [ - {'tool': 'lint', 'status': 'pass', 'summary': 'v1'}]) - checks.store_results(conn, 'msg@ex', [ - {'tool': 'lint', 'status': 'fail', 'summary': 'v2'}]) + checks.store_results( + conn, 'msg@ex', [{'tool': 'lint', 'status': 'pass', 'summary': 'v1'}] + ) + checks.store_results( + conn, 'msg@ex', [{'tool': 'lint', 'status': 'fail', 'summary': 'v2'}] + ) cached = checks.get_cached_results(conn, ['msg@ex']) assert cached['msg@ex'][0]['status'] == 'fail' assert cached['msg@ex'][0]['summary'] == 'v2' @@ -81,10 +96,8 @@ class TestCacheDb: def test_delete_results(self, tmp_path: pytest.TempPathFactory) -> None: conn = checks.get_db() - checks.store_results(conn, 'a@ex', [ - {'tool': 't1', 'status': 'pass'}]) - checks.store_results(conn, 'b@ex', [ - {'tool': 't1', 'status': 'pass'}]) + checks.store_results(conn, 'a@ex', [{'tool': 't1', 'status': 'pass'}]) + checks.store_results(conn, 'b@ex', [{'tool': 't1', 'status': 'pass'}]) checks.delete_results(conn, ['a@ex']) cached = checks.get_cached_results(conn, ['a@ex', 'b@ex']) assert 'a@ex' not in cached @@ -98,16 +111,17 @@ class TestCacheDb: def test_cleanup_old(self, tmp_path: pytest.TempPathFactory) -> None: conn = checks.get_db() - checks.store_results(conn, 'recent@ex', [ - {'tool': 't', 'status': 'pass'}]) + checks.store_results(conn, 'recent@ex', [{'tool': 't', 'status': 'pass'}]) # Manually backdate one row - old_date = (datetime.datetime.now(datetime.timezone.utc) - - datetime.timedelta(days=200)).isoformat() + old_date = ( + datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=200) + ).isoformat() conn.execute( - "INSERT OR REPLACE INTO check_results" - " (msgid, tool, status, checked_at)" - " VALUES (?, ?, ?, ?)", - ('old@ex', 't', 'pass', old_date)) + 'INSERT OR REPLACE INTO check_results' + ' (msgid, tool, status, checked_at)' + ' VALUES (?, ?, ?, ?)', + ('old@ex', 't', 'pass', old_date),