From: Tamir Duberstein <tamird@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
Tamir Duberstein <tamird@kernel.org>,
"str = 'reply"@example.com', "str = 'reviewer"@example.com',
"str = 'author"@example.com', "str = 'patch1"@example.com'
Subject: [PATCH b4 v2 04/11] Add ruff format check to CI
Date: Sun, 19 Apr 2026 11:59:59 -0400 [thread overview]
Message-ID: <20260419-ruff-check-v2-4-089dfb264501@kernel.org> (raw)
In-Reply-To: <20260419-ruff-check-v2-0-089dfb264501@kernel.org>
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 <tamird@kernel.org>
---
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.here@example.com>
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', 'unknown@example.com'))
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 <konstantin@linuxfoundation.org>'
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 <konstantin@linuxfoundation.org>'
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
-@pytest.fixture(scope="function", autouse=True)
+@pytest.fixture(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]
-@pytest.fixture(scope="function")
+@pytest.fixture(scope='function')
def sampledir(request: pytest.FixtureRequest) -> str:
return os.path.join(request.path.parent, 'samples')
-@pytest.fixture(scope="function")
-def gitdir(request: pytest.FixtureRequest, tmp_path: pathlib.Path) -> Generator[str, None, None]:
+@pytest.fixture(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
-@pytest.mark.parametrize('source,expected', [
- ('good-valid-trusted', (True, True, True, 'B6C41CE35664996C', '1623274836')),
- ('good-valid-notrust', (True, True, False, 'B6C41CE35664996C', '1623274836')),
- ('good-invalid-notrust', (True, False, False, 'B6C41CE35664996C', None)),
- ('badsig', (False, False, False, 'B6C41CE35664996C', None)),
- ('no-pubkey', (False, False, False, None, None)),
-])
-def test_check_gpg_status(sampledir: str, source: str, expected: Tuple[bool, bool, bool, Optional[str], Optional[str]]) -> None:
+@pytest.mark.parametrize(
+ 'source,expected',
+ [
+ ('good-valid-trusted', (True, True, True, 'B6C41CE35664996C', '1623274836')),
+ ('good-valid-notrust', (True, True, False, 'B6C41CE35664996C', '1623274836')),
+ ('good-invalid-notrust', (True, False, False, 'B6C41CE35664996C', None)),
+ ('badsig', (False, False, False, 'B6C41CE35664996C', None)),
+ ('no-pubkey', (False, False, False, None, None)),
+ ],
+)
+def test_check_gpg_status(
+ 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
-@pytest.mark.parametrize('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:
+@pytest.mark.parametrize(
+ '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()
-@pytest.mark.parametrize('source,expected', [
- ('trailers-test-simple',
- [('person', 'Reported-by', '"Doe, Jane" <jane@example.com>', None),
- ('person', 'Reviewed-by', 'Bogus Bupkes <bogus@example.com>', None),
- ('utility', 'Fixes', 'abcdef01234567890', None),
- ('utility', 'Link', 'https://msgid.link/some@msgid.here', None),
- ]),
- ('trailers-test-extinfo',
- [('person', 'Reported-by', 'Some, One <somewhere@example.com>', None),
- ('person', 'Reviewed-by', 'Bogus Bupkes <bogus@example.com>', '[for the parts that are bogus]'),
- ('utility', 'Fixes', 'abcdef01234567890', None),
- ('person', 'Tested-by', 'Some Person <bogus2@example.com>', ' [this person visually indented theirs]'),
- ('utility', 'Link', 'https://msgid.link/some@msgid.here', ' # initial submission'),
- ('person', 'Signed-off-by', 'Wrapped Persontrailer <broken@example.com>', None),
- ]),
-])
-def test_parse_trailers(sampledir: str, source: str, expected: List[Tuple[str, str, str, Optional[str]]]) -> None:
+@pytest.mark.parametrize(
+ 'source,expected',
+ [
+ (
+ 'trailers-test-simple',
+ [
+ ('person', 'Reported-by', '"Doe, Jane" <jane@example.com>', None),
+ ('person', 'Reviewed-by', 'Bogus Bupkes <bogus@example.com>', None),
+ ('utility', 'Fixes', 'abcdef01234567890', None),
+ ('utility', 'Link', 'https://msgid.link/some@msgid.here', None),
+ ],
+ ),
+ (
+ 'trailers-test-extinfo',
+ [
+ ('person', 'Reported-by', 'Some, One <somewhere@example.com>', None),
+ (
+ 'person',
+ 'Reviewed-by',
+ 'Bogus Bupkes <bogus@example.com>',
+ '[for the parts that are bogus]',
+ ),
+ ('utility', 'Fixes', 'abcdef01234567890', None),
+ (
+ 'person',
+ 'Tested-by',
+ 'Some Person <bogus2@example.com>',
+ ' [this person visually indented theirs]',
+ ),
+ (
+ 'utility',
+ 'Link',
+ 'https://msgid.link/some@msgid.here',
+ ' # initial submission',
+ ),
+ (
+ 'person',
+ 'Signed-off-by',
+ 'Wrapped Persontrailer <broken@example.com>',
+ 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
-@pytest.mark.parametrize('name,value,exp_type,exp_addr,exp_value', [
- # Simple name
- ('Signed-off-by', 'Simple Name <simple@example.com>',
- 'person', ('Simple Name', 'simple@example.com'),
- 'Simple Name <simple@example.com>'),
- # Double quotes in display name must be preserved
- ('Signed-off-by', 'Jane "JD" Doe <jd@example.com>',
- 'person', ('Jane "JD" Doe', 'jd@example.com'),
- 'Jane "JD" Doe <jd@example.com>'),
- # Outer RFC 2822 quotes around a name with comma
- ('Reported-by', '"Doe, Jane" <jane@example.com>',
- 'person', ('"Doe, Jane"', 'jane@example.com'),
- '"Doe, Jane" <jane@example.com>'),
- # Comma in name without quotes
- ('Reported-by', 'Some, One <somewhere@example.com>',
- 'person', ('Some, One', 'somewhere@example.com'),
- 'Some, One <somewhere@example.com>'),
- # Parentheses in display name
- ('Tested-by', 'Developer Foo (EXAMPLECORP) <dev@example.com>',
- 'person', ('Developer Foo (EXAMPLECORP)', 'dev@example.com'),
- 'Developer Foo (EXAMPLECORP) <dev@example.com>'),
- # Bare angle-bracket email
- ('Cc', '<bare@example.com>',
- 'person', ('', 'bare@example.com'),
- 'bare@example.com'),
- # Bare email without angle brackets
- ('Cc', 'bare@example.com',
- 'person', ('', 'bare@example.com'),
- 'bare@example.com'),
-])
-def test_trailer_addr_parsing(name: str, value: str, exp_type: str,
- exp_addr: Tuple[str, str], exp_value: str) -> None:
+@pytest.mark.parametrize(
+ 'name,value,exp_type,exp_addr,exp_value',
+ [
+ # Simple name
+ (
+ 'Signed-off-by',
+ 'Simple Name <simple@example.com>',
+ 'person',
+ ('Simple Name', 'simple@example.com'),
+ 'Simple Name <simple@example.com>',
+ ),
+ # Double quotes in display name must be preserved
+ (
+ 'Signed-off-by',
+ 'Jane "JD" Doe <jd@example.com>',
+ 'person',
+ ('Jane "JD" Doe', 'jd@example.com'),
+ 'Jane "JD" Doe <jd@example.com>',
+ ),
+ # Outer RFC 2822 quotes around a name with comma
+ (
+ 'Reported-by',
+ '"Doe, Jane" <jane@example.com>',
+ 'person',
+ ('"Doe, Jane"', 'jane@example.com'),
+ '"Doe, Jane" <jane@example.com>',
+ ),
+ # Comma in name without quotes
+ (
+ 'Reported-by',
+ 'Some, One <somewhere@example.com>',
+ 'person',
+ ('Some, One', 'somewhere@example.com'),
+ 'Some, One <somewhere@example.com>',
+ ),
+ # Parentheses in display name
+ (
+ 'Tested-by',
+ 'Developer Foo (EXAMPLECORP) <dev@example.com>',
+ 'person',
+ ('Developer Foo (EXAMPLECORP)', 'dev@example.com'),
+ 'Developer Foo (EXAMPLECORP) <dev@example.com>',
+ ),
+ # Bare angle-bracket email
+ (
+ 'Cc',
+ '<bare@example.com>',
+ 'person',
+ ('', 'bare@example.com'),
+ 'bare@example.com',
+ ),
+ # Bare email without angle brackets
+ (
+ 'Cc',
+ 'bare@example.com',
+ 'person',
+ ('', 'bare@example.com'),
+ 'bare@example.com',
+ ),
+ ],
+)
+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
-@pytest.mark.parametrize('source,serargs,amargs,reference,b4cfg', [
- ('single', {}, {}, 'defaults', {}),
- ('single', {}, {'noaddtrailers': True}, 'noadd', {}),
- ('single', {}, {'addmysob': True}, 'addmysob', {}),
- ('single', {}, {'addmysob': True, 'copyccs': True}, 'copyccs', {}),
- ('single', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}),
- ('single', {}, {'addmysob': True, 'addlink': True}, 'addmsgid', {'linktrailermask': 'Message-ID: <%s>'}),
- ('single', {}, {'addmysob': True, 'copyccs': True}, 'ordered',
- {'trailer-order': 'Cc,Tested*,Reviewed*,*'}),
- ('single', {'sloppytrailers': True}, {'addmysob': True}, 'sloppy', {}),
- ('with-cover', {}, {'addmysob': True}, 'defaults', {}),
- ('with-cover', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}),
- ('custody', {}, {'addmysob': True, 'copyccs': True}, 'unordered', {}),
- ('custody', {}, {'addmysob': True, 'copyccs': True}, 'ordered',
- {'trailer-order': 'Cc,Fixes*,Link*,Suggested*,Reviewed*,Tested*,*'}),
- ('custody', {}, {'addmysob': True, 'copyccs': True}, 'with-ignored',
- {'trailers-ignore-from': 'followup-reviewer1@example.com'}),
- ('partial-reroll', {}, {'addmysob': True}, 'defaults', {}),
- ('nore', {}, {}, 'defaults', {}),
- ('non-git-patch', {}, {}, 'defaults', {}),
- ('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:
+@pytest.mark.parametrize(
+ 'source,serargs,amargs,reference,b4cfg',
+ [
+ ('single', {}, {}, 'defaults', {}),
+ ('single', {}, {'noaddtrailers': True}, 'noadd', {}),
+ ('single', {}, {'addmysob': True}, 'addmysob', {}),
+ ('single', {}, {'addmysob': True, 'copyccs': True}, 'copyccs', {}),
+ ('single', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}),
+ (
+ 'single',
+ {},
+ {'addmysob': True, 'addlink': True},
+ 'addmsgid',
+ {'linktrailermask': 'Message-ID: <%s>'},
+ ),
+ (
+ 'single',
+ {},
+ {'addmysob': True, 'copyccs': True},
+ 'ordered',
+ {'trailer-order': 'Cc,Tested*,Reviewed*,*'},
+ ),
+ ('single', {'sloppytrailers': True}, {'addmysob': True}, 'sloppy', {}),
+ ('with-cover', {}, {'addmysob': True}, 'defaults', {}),
+ ('with-cover', {}, {'addmysob': True, 'addlink': True}, 'addlink', {}),
+ ('custody', {}, {'addmysob': True, 'copyccs': True}, 'unordered', {}),
+ (
+ 'custody',
+ {},
+ {'addmysob': True, 'copyccs': True},
+ 'ordered',
+ {'trailer-order': 'Cc,Fixes*,Link*,Suggested*,Reviewed*,Tested*,*'},
+ ),
+ (
+ 'custody',
+ {},
+ {'addmysob': True, 'copyccs': True},
+ 'with-ignored',
+ {'trailers-ignore-from': 'followup-reviewer1@example.com'},
+ ),
+ ('partial-reroll', {}, {'addmysob': True}, 'defaults', {}),
+ ('nore', {}, {}, 'defaults', {}),
+ ('non-git-patch', {}, {}, 'defaults', {}),
+ ('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()
-@pytest.mark.parametrize('hval,verify,tr', [
- ('short-ascii', 'short-ascii', 'encode'),
- ('short-unicôde', '=?utf-8?q?short-unic=C3=B4de?=', 'encode'),
- # Long ascii
- (('Lorem ipsum dolor sit amet consectetur adipiscing elit '
- 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'),
- ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n'
- ' eiusmod tempor incididunt ut labore et dolore magna aliqua'), 'encode'),
- # Long unicode
- (('Lorem îpsum dolor sit amet consectetur adipiscing elît '
- 'sed do eiusmod tempôr incididunt ut labore et dolôre magna aliqua'),
- ('=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n'
- ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n'
- ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?='), 'encode'),
- # Exactly 75 long
- ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu',
- 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu', 'encode'),
- # Unicode that breaks on escape boundary
- ('Lorem ipsum dolor sit amet consectetur adipiscin elît',
- '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n =?utf-8?q?=C3=AEt?=', 'encode'),
- # Unicode that's just 1 too long
- ('Lorem ipsum dolor sit amet consectetur adipi elît',
- '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n =?utf-8?q?t?=', 'encode'),
- # A single address
- ('foo@example.com', 'foo@example.com', 'encode'),
- # Two addresses
- ('foo@example.com, bar@example.com', 'foo@example.com, bar@example.com', 'encode'),
- # Mixed addresses
- ('foo@example.com, Foo Bar <bar@example.com>', 'foo@example.com, Foo Bar <bar@example.com>', 'encode'),
- # Mixed Unicode
- ('foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>',
- 'foo@example.com, Foo Bar <bar@example.com>, \n =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>', 'encode'),
- ('foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, "Quux, Foo" <quux@example.com>',
- ('foo@example.com, Foo Bar <bar@example.com>, \n'
- ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, "Quux, Foo" <quux@example.com>'), 'encode'),
- ('01234567890123456789012345678901234567890123456789012345678901@example.org, ä <foo@example.org>',
- ('01234567890123456789012345678901234567890123456789012345678901@example.org, \n'
- ' =?utf-8?q?=C3=A4?= <foo@example.org>'), 'encode'),
- # Test for https://github.com/python/cpython/issues/100900
- ('foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, "Quûx, Foo" <quux@example.com>',
- ('foo@example.com, Foo Bar <bar@example.com>, \n'
- ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, \n =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>'), 'encode'),
- # Test preserve
- ('foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, "Quûx, Foo" <quux@example.com>',
- 'foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, \n "Quûx, Foo" <quux@example.com>',
- 'preserve'),
- # Test decode
- ('foo@example.com, Foo Bar <bar@example.com>, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>',
- 'foo@example.com, Foo Bar <bar@example.com>, \n "Quûx, Foo" <quux@example.com>',
- 'decode'),
- # Test short message-id
- ('Message-ID: <20240319-short-message-id@example.com>', '<20240319-short-message-id@example.com>', '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:
+@pytest.mark.parametrize(
+ 'hval,verify,tr',
+ [
+ ('short-ascii', 'short-ascii', 'encode'),
+ ('short-unicôde', '=?utf-8?q?short-unic=C3=B4de?=', 'encode'),
+ # Long ascii
+ (
+ (
+ 'Lorem ipsum dolor sit amet consectetur adipiscing elit '
+ 'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'
+ ),
+ (
+ 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n'
+ ' eiusmod tempor incididunt ut labore et dolore magna aliqua'
+ ),
+ 'encode',
+ ),
+ # Long unicode
+ (
+ (
+ 'Lorem îpsum dolor sit amet consectetur adipiscing elît '
+ 'sed do eiusmod tempôr incididunt ut labore et dolôre magna aliqua'
+ ),
+ (
+ '=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n'
+ ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n'
+ ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?='
+ ),
+ 'encode',
+ ),
+ # Exactly 75 long
+ (
+ 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu',
+ 'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu',
+ 'encode',
+ ),
+ # Unicode that breaks on escape boundary
+ (
+ 'Lorem ipsum dolor sit amet consectetur adipiscin elît',
+ '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n =?utf-8?q?=C3=AEt?=',
+ 'encode',
+ ),
+ # Unicode that's just 1 too long
+ (
+ 'Lorem ipsum dolor sit amet consectetur adipi elît',
+ '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n =?utf-8?q?t?=',
+ 'encode',
+ ),
+ # A single address
+ ('foo@example.com', 'foo@example.com', 'encode'),
+ # Two addresses
+ (
+ 'foo@example.com, bar@example.com',
+ 'foo@example.com, bar@example.com',
+ 'encode',
+ ),
+ # Mixed addresses
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>',
+ 'encode',
+ ),
+ # Mixed Unicode
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>, \n =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>',
+ 'encode',
+ ),
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, "Quux, Foo" <quux@example.com>',
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, \n'
+ ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, "Quux, Foo" <quux@example.com>'
+ ),
+ 'encode',
+ ),
+ (
+ '01234567890123456789012345678901234567890123456789012345678901@example.org, ä <foo@example.org>',
+ (
+ '01234567890123456789012345678901234567890123456789012345678901@example.org, \n'
+ ' =?utf-8?q?=C3=A4?= <foo@example.org>'
+ ),
+ 'encode',
+ ),
+ # Test for https://github.com/python/cpython/issues/100900
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, "Quûx, Foo" <quux@example.com>',
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, \n'
+ ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, \n =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>'
+ ),
+ 'encode',
+ ),
+ # Test preserve
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, "Quûx, Foo" <quux@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>, Fôo Baz <baz@example.com>, \n "Quûx, Foo" <quux@example.com>',
+ 'preserve',
+ ),
+ # Test decode
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>, \n "Quûx, Foo" <quux@example.com>',
+ 'decode',
+ ),
+ # Test short message-id
+ (
+ 'Message-ID: <20240319-short-message-id@example.com>',
+ '<20240319-short-message-id@example.com>',
+ '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
-@pytest.mark.parametrize('pairs,verify,clean', [
- ([('', 'foo@example.com'), ('Foo Bar', 'bar@example.com')],
- 'foo@example.com, Foo Bar <bar@example.com>', True),
- ([('', 'foo@example.com'), ('Foo, Bar', 'bar@example.com')],
- 'foo@example.com, "Foo, Bar" <bar@example.com>', True),
- ([('', 'foo@example.com'), ('Fôo, Bar', 'bar@example.com')],
- 'foo@example.com, "Fôo, Bar" <bar@example.com>', True),
- ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx_Foo?=', 'quux@example.com')],
- 'foo@example.com, Quûx Foo <quux@example.com>', True),
- ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com')],
- 'foo@example.com, "Quûx, Foo" <quux@example.com>', True),
- ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com')],
- 'foo@example.com, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>', False),
- # Pre-quoted display name with special chars must not be double-quoted
- ([('', 'foo@example.com'), ('"Example.org Tools"', 'tools@example.org')],
- 'foo@example.com, "Example.org Tools" <tools@example.org>', True),
- ([('', 'foo@example.com'), ('"Doe, Jane"', 'jane@example.com')],
- 'foo@example.com, "Doe, Jane" <jane@example.com>', True),
- # Unquoted name with internal quotes
- ([('', 'foo@example.com'), ('Jane "JD" Doe', 'jd@example.com')],
- 'foo@example.com, "Jane \\"JD\\" Doe" <jd@example.com>', True),
- # Name starting with quote but not fully quoted
- ([('', 'foo@example.com'), ('"JD" Doe', 'jd@example.com')],
- 'foo@example.com, "\\"JD\\" Doe" <jd@example.com>', True),
- # Pre-quoted name with internal quotes
- ([('', 'foo@example.com'), ('"Jane "JD" Doe"', 'jd@example.com')],
- 'foo@example.com, "Jane \\"JD\\" Doe" <jd@example.com>', True),
-])
+@pytest.mark.parametrize(
+ 'pairs,verify,clean',
+ [
+ (
+ [('', 'foo@example.com'), ('Foo Bar', 'bar@example.com')],
+ 'foo@example.com, Foo Bar <bar@example.com>',
+ True,
+ ),
+ (
+ [('', 'foo@example.com'), ('Foo, Bar', 'bar@example.com')],
+ 'foo@example.com, "Foo, Bar" <bar@example.com>',
+ True,
+ ),
+ (
+ [('', 'foo@example.com'), ('Fôo, Bar', 'bar@example.com')],
+ 'foo@example.com, "Fôo, Bar" <bar@example.com>',
+ True,
+ ),
+ (
+ [
+ ('', 'foo@example.com'),
+ ('=?utf-8?q?Qu=C3=BBx_Foo?=', 'quux@example.com'),
+ ],
+ 'foo@example.com, Quûx Foo <quux@example.com>',
+ True,
+ ),
+ (
+ [
+ ('', 'foo@example.com'),
+ ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com'),
+ ],
+ 'foo@example.com, "Quûx, Foo" <quux@example.com>',
+ True,
+ ),
+ (
+ [
+ ('', 'foo@example.com'),
+ ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com'),
+ ],
+ 'foo@example.com, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>',
+ False,
+ ),
+ # Pre-quoted display name with special chars must not be double-quoted
+ (
+ [('', 'foo@example.com'), ('"Example.org Tools"', 'tools@example.org')],
+ 'foo@example.com, "Example.org Tools" <tools@example.org>',
+ True,
+ ),
+ (
+ [('', 'foo@example.com'), ('"Doe, Jane"', 'jane@example.com')],
+ 'foo@example.com, "Doe, Jane" <jane@example.com>',
+ True,
+ ),
+ # Unquoted name with internal quotes
+ (
+ [('', 'foo@example.com'), ('Jane "JD" Doe', 'jd@example.com')],
+ 'foo@example.com, "Jane \\"JD\\" Doe" <jd@example.com>',
+ True,
+ ),
+ # Name starting with quote but not fully quoted
+ (
+ [('', 'foo@example.com'), ('"JD" Doe', 'jd@example.com')],
+ 'foo@example.com, "\\"JD\\" Doe" <jd@example.com>',
+ True,
+ ),
+ # Pre-quoted name with internal quotes
+ (
+ [('', 'foo@example.com'), ('"Jane "JD" Doe"', 'jd@example.com')],
+ 'foo@example.com, "Jane \\"JD\\" Doe" <jd@example.com>',
+ True,
+ ),
+ ],
+)
def test_format_addrs(pairs: List[Tuple[str, str]], verify: str, clean: bool) -> None:
formatted = b4.format_addrs(pairs, clean)
assert formatted == verify
-@pytest.mark.parametrize('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]),
-])
+@pytest.mark.parametrize(
+ '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
-@pytest.mark.parametrize('body_link,extra_link,expect_count', [
- # Exact same URL — should dedup to one
- ('https://patch.msgid.link/20240101-test-v1-1-abc123@example.com',
- 'https://patch.msgid.link/20240101-test-v1-1-abc123@example.com', 1),
- # Same URL, different case — should still dedup
- ('https://patch.msgid.link/20240101-TEST-V1-1-ABC123@example.com',
- 'https://patch.msgid.link/20240101-test-v1-1-abc123@example.com', 1),
- # Different domains, same message-id — should dedup to one
- ('https://lore.kernel.org/r/20240101-test-v1-1-abc123@example.com',
- 'https://patch.msgid.link/20240101-test-v1-1-abc123@example.com', 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/20240101-test-v1-1-abc123@example.com', 1),
- # Different message-ids — both should survive
- ('https://lore.kernel.org/r/20240101-foo-v1-1-aaa@example.com',
- 'https://patch.msgid.link/20240101-bar-v1-1-bbb@example.com', 2),
-])
+@pytest.mark.parametrize(
+ 'body_link,extra_link,expect_count',
+ [
+ # Exact same URL — should dedup to one
+ (
+ 'https://patch.msgid.link/20240101-test-v1-1-abc123@example.com',
+ 'https://patch.msgid.link/20240101-test-v1-1-abc123@example.com',
+ 1,
+ ),
+ # Same URL, different case — should still dedup
+ (
+ 'https://patch.msgid.link/20240101-TEST-V1-1-ABC123@example.com',
+ 'https://patch.msgid.link/20240101-test-v1-1-abc123@example.com',
+ 1,
+ ),
+ # Different domains, same message-id — should dedup to one
+ (
+ 'https://lore.kernel.org/r/20240101-test-v1-1-abc123@example.com',
+ 'https://patch.msgid.link/20240101-test-v1-1-abc123@example.com',
+ 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/20240101-test-v1-1-abc123@example.com',
+ 1,
+ ),
+ # Different message-ids — both should survive
+ (
+ 'https://lore.kernel.org/r/20240101-foo-v1-1-aaa@example.com',
+ 'https://patch.msgid.link/20240101-bar-v1-1-bbb@example.com',
+ 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 <reviewer@example.com>' in result
assert 'Acked-by: Acker Two <acker@example.com>' 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 <alice@example.com>' 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
-@pytest.mark.parametrize('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:
+@pytest.mark.parametrize(
+ '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
-@pytest.fixture(scope="function")
+@pytest.fixture(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
-@pytest.mark.parametrize('mboxf, bundlef, rep, trargs, compareargs, compareout, b4cfg', [
- ('trailers-thread-with-followups', None, None, [],
- ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups',
- {'shazam-am-flags': '--signoff'}),
- ('trailers-thread-with-cover-followup', None, None, [],
- ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-cover-followup',
- {'shazam-am-flags': '--signoff'}),
- # Test matching trailer updates by subject when patch-id changes
- ('trailers-thread-with-followups', None, (b'vivendum', b'addendum'), [],
- ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'], 'trailers-thread-with-followups-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:
+@pytest.mark.parametrize(
+ 'mboxf, bundlef, rep, trargs, compareargs, compareout, b4cfg',
+ [
+ (
+ 'trailers-thread-with-followups',
+ None,
+ None,
+ [],
+ ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'],
+ 'trailers-thread-with-followups',
+ {'shazam-am-flags': '--signoff'},
+ ),
+ (
+ 'trailers-thread-with-cover-followup',
+ None,
+ None,
+ [],
+ ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'],
+ 'trailers-thread-with-cover-followup',
+ {'shazam-am-flags': '--signoff'},
+ ),
+ # Test matching trailer updates by subject when patch-id changes
+ (
+ 'trailers-thread-with-followups',
+ None,
+ (b'vivendum', b'addendum'),
+ [],
+ ['log', '--format=%ae%n%s%n%b---', 'HEAD~4..'],
+ 'trailers-thread-with-followups-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
-@pytest.mark.parametrize('mboxf, shazamargs, compareargs, compareout, b4cfg', [
- ('shazam-git1-just-series', [],
- ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'], 'shazam-git1-just-series-defaults', {}),
- ('shazam-git1-just-series', ['-H'],
- ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD..FETCH_HEAD'], 'shazam-git1-just-series-defaults', {}),
- ('shazam-git1-just-series', ['-M'],
- ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD^..'], 'shazam-git1-just-series-merged', {}),
- # --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:
+@pytest.mark.parametrize(
+ 'mboxf, shazamargs, compareargs, compareout, b4cfg',
+ [
+ (
+ 'shazam-git1-just-series',
+ [],
+ ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD~4..'],
+ 'shazam-git1-just-series-defaults',
+ {},
+ ),
+ (
+ 'shazam-git1-just-series',
+ ['-H'],
+ ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD..FETCH_HEAD'],
+ 'shazam-git1-just-series-defaults',
+ {},
+ ),
+ (
+ 'shazam-git1-just-series',
+ ['-M'],
+ ['log', '--format=%ae%n%ce%n%s%n%b---', 'HEAD^..'],
+ 'shazam-git1-just-series-merged',
+ {},
+ ),
+ # --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 <author@example.com>',
'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='<v2-cover@example.com>',
)
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, 'new@example.com', 'Seen',
- msg_date='2026-03-05T10:00:00')
+ messages.set_flag(
+ conn, 'new@example.com', 'Seen', msg_date='2026-03-05T10:00:00'
+ )
flags = messages.get_flags(conn, 'new@example.com')
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, 'old@example.com', 'Seen', msg_date=old_date)
- messages.set_flag(conn, 'recent@example.com', 'Seen',
- msg_date=datetime.datetime.now(
- datetime.timezone.utc).isoformat())
+ messages.set_flag(
+ conn,
+ 'recent@example.com',
+ '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, 'old@example.com') == ''
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, 'test@example.com', '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 = 'test@example.com',
- subject: str = 'Test patch',
- body: str = 'This is a test.\n') -> bytes:
+def _make_test_message(
+ from_addr: str = 'test@example.com',
+ 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 = 'test@example.com',
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 <test@example.com>',
- 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 <test@example.com>',
+ 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('20260323041505.2088-1-user@example.com') == \
- '20260323041505.2088-1-user@example.com'
+ assert (
+ b4.parse_msgid('20260323041505.2088-1-user@example.com')
+ == '20260323041505.2088-1-user@example.com'
+ )
def test_angle_brackets(self) -> None:
- assert b4.parse_msgid('<20260323041505.2088-1-user@example.com>') == \
- '20260323041505.2088-1-user@example.com'
+ assert (
+ b4.parse_msgid('<20260323041505.2088-1-user@example.com>')
+ == '20260323041505.2088-1-user@example.com'
+ )
def test_lore_url(self) -> None:
result = b4.parse_msgid(
- 'https://lore.kernel.org/all/20260323041505.2088-1-user@example.com/')
+ 'https://lore.kernel.org/all/20260323041505.2088-1-user@example.com/'
+ )
assert result == '20260323041505.2088-1-user@example.com'
def test_lore_url_with_r_shorthand(self) -> None:
result = b4.parse_msgid(
- 'https://lore.kernel.org/r/20260323041505.2088-1-user@example.com')
+ 'https://lore.kernel.org/r/20260323041505.2088-1-user@example.com'
+ )
assert result == '20260323041505.2088-1-user@example.com'
def test_lore_url_percent_encoded(self) -> None:
- result = b4.parse_msgid(
- 'https://lore.kernel.org/all/abc%2Bdef@example.com/')
+ result = b4.parse_msgid('https://lore.kernel.org/all/abc%2Bdef@example.com/')
assert result == 'abc+def@example.com'
def test_patchwork_url(self) -> None:
result = b4.parse_msgid(
- 'https://patchwork.kernel.org/project/linux-mm/patch/20260323041505.2088-1-user@example.com/')
+ 'https://patchwork.kernel.org/project/linux-mm/patch/20260323041505.2088-1-user@example.com/'
+ )
assert result == '20260323041505.2088-1-user@example.com'
def test_id_prefix(self) -> None:
- assert b4.parse_msgid('id:20260323041505.2088-1-user@example.com') == \
- '20260323041505.2088-1-user@example.com'
+ assert (
+ b4.parse_msgid('id:20260323041505.2088-1-user@example.com')
+ == '20260323041505.2088-1-user@example.com'
+ )
def test_rfc822msgid_prefix(self) -> None:
- assert b4.parse_msgid('rfc822msgid:20260323041505.2088-1-user@example.com') == \
- '20260323041505.2088-1-user@example.com'
+ assert (
+ b4.parse_msgid('rfc822msgid:20260323041505.2088-1-user@example.com')
+ == '20260323041505.2088-1-user@example.com'
+ )
def test_whitespace_stripped(self) -> None:
assert b4.parse_msgid(' <foo@bar.com> ') == 'foo@bar.com'
@@ -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 <rev@example.com>',
- body='Looks good.\n\nReviewed-by: Reviewer <rev@example.com>\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 <rev@example.com>',
+ body='Looks good.\n\nReviewed-by: Reviewer <rev@example.com>\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, {}, 'me@example.com')
+ SIMPLE_DIFF, {}, 'me@example.com'
+ )
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:
'me@example.com': {
'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, 'me@example.com')
+ SIMPLE_DIFF, all_reviews, 'me@example.com'
+ )
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, 'me@example.com')
+ SIMPLE_DIFF, all_reviews, 'me@example.com'
+ )
assert '| Looks wrong.' in result
assert '| Other <other@example.com>:' in result
@@ -109,7 +117,8 @@ class TestRenderQuotedDiffWithComments:
},
}
result = review._render_quoted_diff_with_comments(
- SIMPLE_DIFF, all_reviews, 'me@example.com')
+ SIMPLE_DIFF, all_reviews, 'me@example.com'
+ )
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, 'me@example.com')
+ TWO_FILE_DIFF, all_reviews, 'me@example.com'
+ )
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, {}, 'me@example.com')
+ SIMPLE_DIFF, {}, 'me@example.com'
+ )
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, {}, 'me@example.com',
- commit_msg='Subject line\n\nThis is the body.\nSecond line.')
+ SIMPLE_DIFF,
+ {},
+ 'me@example.com',
+ 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, 'me@example.com',
- commit_msg='Subject\n\nFirst body line.')
+ SIMPLE_DIFF,
+ all_reviews,
+ 'me@example.com',
+ 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, 'me@example.com',
- commit_msg='Subject\n\nFirst body line.')
+ SIMPLE_DIFF,
+ all_reviews,
+ 'me@example.com',
+ commit_msg='Subject\n\nFirst body line.',
+ )
assert '| Ext msg comment' in result
assert '| Other <other@example.com>:' 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, 'me@example.com',
- commit_msg='Subject\n\nFirst body line.')
+ SIMPLE_DIFF,
+ all_reviews,
+ 'me@example.com',
+ 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:
'me@example.com': {'name': 'Me', 'comments': comments},
}
rendered = review._render_quoted_diff_with_comments(
- SIMPLE_DIFF, all_reviews, 'me@example.com')
+ SIMPLE_DIFF, all_reviews, 'me@example.com'
+ )
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, 'me@example.com')
+ TWO_FILE_DIFF, all_reviews, 'me@example.com'
+ )
extracted = review._extract_editor_comments(rendered)
assert len(extracted) == 2
assert extracted[0]['path'] == 'b/src/a.c'
@@ -401,14 +425,16 @@ class TestQuotedEditorRoundTrip:
'me@example.com': {'name': 'Me', 'comments': comments},
}
rendered1 = review._render_quoted_diff_with_comments(
- SIMPLE_DIFF, all_reviews, 'me@example.com')
+ SIMPLE_DIFF, all_reviews, 'me@example.com'
+ )
extracted1 = review._extract_editor_comments(rendered1)
all_reviews2: Dict[str, Any] = {
'me@example.com': {'name': 'Me', 'comments': extracted1},
}
rendered2 = review._render_quoted_diff_with_comments(
- SIMPLE_DIFF, all_reviews2, 'me@example.com')
+ SIMPLE_DIFF, all_reviews2, 'me@example.com'
+ )
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, 'me@example.com')
+ SIMPLE_DIFF, all_reviews, 'me@example.com'
+ )
extracted = review._extract_editor_comments(rendered)
assert len(extracted) == 1
assert extracted[0]['text'] == 'My note'
@@ -446,8 +473,11 @@ class TestQuotedEditorRoundTrip:
'me@example.com': {'name': 'Me', 'comments': comments},
}
rendered = review._render_quoted_diff_with_comments(
- SIMPLE_DIFF, all_reviews, 'me@example.com',
- commit_msg='Subject\n\nFirst body line.\nSecond line.')
+ SIMPLE_DIFF,
+ all_reviews,
+ 'me@example.com',
+ 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:
'me@example.com': {'name': 'Me', 'comments': comments},
}
rendered = review._render_quoted_diff_with_comments(
- SIMPLE_DIFF, all_reviews, 'me@example.com',
- commit_msg='Subject\n\nBody line.')
+ SIMPLE_DIFF,
+ all_reviews,
+ 'me@example.com',
+ 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:
'me@example.com': {'name': 'Me', 'comments': comments},
}
rendered = review._render_quoted_diff_with_comments(
- SIMPLE_DIFF, all_reviews, 'me@example.com',
- commit_msg='Subject\n\nFirst body line.')
+ SIMPLE_DIFF,
+ all_reviews,
+ 'me@example.com',
+ 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 <a@b.c>'
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 <a@b.c>\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 <a@b.c>\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 <test@example.com>']}
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ def test_bcc_set_when_present(
+ self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock
+ ) -> None:
series = self._make_series(bcc='secret@example.com')
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'] == 'secret@example.com'
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ def test_cc_still_works(self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock) -> None:
series = self._make_series(cc='other@example.com')
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 'other@example.com' in msg['Cc']
assert 'maintainer@example.com' 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': 'user@example.com', 'name': 'User'}
+ usercfg: Dict[str, Union[str, List[str], None]] = {
+ 'email': 'user@example.com',
+ 'name': 'User',
+ }
entry = review._ensure_my_review(target, usercfg)
assert entry['name'] == 'User'
assert target['reviews']['user@example.com'] 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': {'user@example.com': existing}}
- usercfg: Dict[str, Union[str, List[str], None]] = {'email': 'user@example.com', 'name': 'New Name'}
+ usercfg: Dict[str, Union[str, List[str], None]] = {
+ 'email': 'user@example.com',
+ '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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ def test_reply_to_used_as_to(
+ self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock
+ ) -> None:
series = self._make_series(**{'reply-to': 'list@lists.example.com'})
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 'list@lists.example.com' in msg['To']
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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 'author@example.com' in msg['To']
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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'] == '<test-msgid@example.com>'
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ def test_references_with_existing(
+ self, _mock_cfg: mock.Mock, _mock_sig: mock.Mock
+ ) -> None:
series = self._make_series(references='<prev@example.com>')
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 '<prev@example.com>' in msg['References']
assert '<test-msgid@example.com>' in msg['References']
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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 <test@example.com>' in payload.decode()
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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'] == '<test-msgid@example.com>'
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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 'reviewer@example.com' 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 <test@example.com>']}
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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 'author@example.com' in msg['To']
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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='list@lists.example.com')
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 'author@example.com' in msg['To']
assert 'list@lists.example.com' in msg['Cc']
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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='custom@example.com')
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 'custom@example.com' in msg['To']
assert 'author@example.com' 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': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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='custom@example.com', cc='other@example.com')
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'] == 'custom@example.com'
assert msg['Cc'] == 'other@example.com'
@mock.patch('b4.get_email_signature', return_value='sig')
- @mock.patch('b4.get_user_config', return_value={
- 'name': 'Reviewer', 'email': 'reviewer@example.com'})
- 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': 'reviewer@example.com'},
+ )
+ 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='custom@example.com', 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 = 'reply@example.com',
fromname: str = 'Reviewer',
fromemail: str = 'reviewer@example.com',
@@ -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 <reviewer@example.com>',
+ 'Reviewed-by',
+ 'Reviewer <reviewer@example.com>',
)
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 <author@example.com>\n'
)
ft = self._make_followup_trailer(
- 'Reviewed-by', 'Reviewer <reviewer@example.com>',
+ 'Reviewed-by',
+ 'Reviewer <reviewer@example.com>',
)
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 <author@example.com>\n'
- )
+ body = 'Patch description\n\nSigned-off-by: Author <author@example.com>\n'
ft = self._make_followup_trailer(
- 'Acked-by', 'Acker <acker@example.com>',
+ 'Acked-by',
+ 'Acker <acker@example.com>',
)
lmsg = self._make_lmsg(body, [ft])
result = review._collect_followups(lmsg, self.LINKMASK)
@@ -1514,11 +1654,13 @@ class TestCollectFollowups:
'Signed-off-by: Author <author@example.com>\n'
)
ft_dup = self._make_followup_trailer(
- 'Reviewed-by', 'Reviewer <reviewer@example.com>',
+ 'Reviewed-by',
+ 'Reviewer <reviewer@example.com>',
msgid='reply1@example.com',
)
ft_new = self._make_followup_trailer(
- 'Tested-by', 'Tester <tester@example.com>',
+ 'Tested-by',
+ 'Tester <tester@example.com>',
msgid='reply2@example.com',
fromname='Tester',
fromemail='tester@example.com',
@@ -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 <reviewer@example.com>',
+ 'Reviewed-by',
+ 'Reviewer <reviewer@example.com>',
msgid='reply@example.com',
)
ft2 = self._make_followup_trailer(
- 'Tested-by', 'Reviewer <reviewer@example.com>',
+ 'Tested-by',
+ 'Reviewer <reviewer@example.com>',
msgid='reply@example.com',
)
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 <a@example.com>',
- 'Reviewed-by: R <r@example.com>']},
+ {
+ 'trailers': [
+ 'Acked-by: A <a@example.com>',
+ 'Reviewed-by: R <r@example.com>',
+ ]
+ },
],
patches=[
- {'followups': [
- {'trailers': ['Tested-by: T <t@example.com>',
- 'Acked-by: B <b@example.com>']},
- ]},
+ {
+ 'followups': [
+ {
+ 'trailers': [
+ 'Tested-by: T <t@example.com>',
+ 'Acked-by: B <b@example.com>',
+ ]
+ },
+ ]
+ },
],
)
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 <s@example.com>',
- 'Reviewed-by: R <r@example.com>']},
+ {
+ 'trailers': [
+ 'Signed-off-by: S <s@example.com>',
+ 'Reviewed-by: R <r@example.com>',
+ ]
+ },
],
)
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 <a@example.com>']}]})
+ tracking = json.dumps(
+ {'followups': [{'trailers': ['Acked-by: A <a@example.com>']}]}
+ )
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 <a@example.com>',
- 'Reviewed-by: R <r@example.com>']}],
+ followups=[
+ {
+ 'trailers': [
+ 'Acked-by: A <a@example.com>',
+ 'Reviewed-by: R <r@example.com>',
+ ]
+ }
+ ],
patches=[{'followups': [{'trailers': ['Tested-by: T <t@example.com>']}]}],
)
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/user@example.com', 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/user@example.com'
@@ -1755,7 +1960,9 @@ class TestCheckSeriesAttestation:
"""A badsig attestor is reported with status 'badsig'."""
att = [_make_mock_attestation('badsig', 'ed25519/user@example.com', 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/user@example.com'
@@ -1766,7 +1973,9 @@ class TestCheckSeriesAttestation:
_make_mock_attestation('nokey', 'ed25519/user@example.com', 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 <test@test.com>\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 <test@test.com>\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 <dev@test.com> 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 <dev@test.com> 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 <a@b.com> 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 <a@b.com> 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': 'test@example.com'}
- 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': 'cover@example.com'}
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': 'cover@example.com'}}
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('cover@example.com', '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': 'patch1@example.com'}}]
+ patches: List[Dict[str, Any]] = [
+ {'header-info': {'msgid': 'patch1@example.com'}}
+ ]
series = {'message_id': 'cover@example.com'}
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']['sashiko@sashiko.dev']['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:
'sashiko@sashiko.dev': {
'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': 'cover@example.com'}
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']['sashiko@sashiko.dev']['comments'][0]['text'] == 'Already here.'
+ assert (
+ patches[0]['reviews']['sashiko@sashiko.dev']['comments'][0]['text']
+ == 'Already here.'
+ )
class TestIntegrateFollowupInlineComments:
"""Tests for _integrate_followup_inline_comments()."""
_FOLLOWUP_BODY_WITH_DIFF = (
- "On Mon, Jan 1, 2024, Dev <dev@test.com> 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 <dev@test.com> 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:
'reviewer0@example.com': {
'name': 'Reviewer 0',
'followup-msgid': 'followup1-0@example.com',
- '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']['reviewer0@example.com']['comments'][0]['text'] == 'Already here.'
+ assert (
+ patches[0]['reviews']['reviewer0@example.com']['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, 'reply-1@example.com')
assert item.msgid == 'reply-1@example.com'
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 = 'reply@example.com'
@@ -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 = 'reply@example.com'
# 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 = 'abc@example.com',
- body: str = 'dummy') -> EmailMessage:
+
+def _make_msg(
+ subject: str = 'test patch', msgid: str = 'abc@example.com', 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 = 'abc@example.com',
# 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),
+ )
conn.commit()
deleted = checks.cleanup_old(conn, max_days=180)
assert deleted == 1
@@ -121,6 +135,7 @@ class TestCacheDb:
# parse_cmd
# ---------------------------------------------------------------------------
+
class TestParseCmd:
"""Tests for parse_cmd shell splitting."""
@@ -128,34 +143,39 @@ class TestParseCmd:
assert checks.parse_cmd('/usr/bin/check') == ['/usr/bin/check']
def test_with_args(self) -> None:
- assert checks.parse_cmd('check --verbose -q') == [
- 'check', '--verbose', '-q']
+ assert checks.parse_cmd('check --verbose -q') == ['check', '--verbose', '-q']
def test_quoted_arg(self) -> None:
- assert checks.parse_cmd('check "hello world"') == [
- 'check', 'hello world']
+ assert checks.parse_cmd('check "hello world"') == ['check', 'hello world']
def test_single_quotes(self) -> None:
- assert checks.parse_cmd("check 'hello world'") == [
- 'check', 'hello world']
+ assert checks.parse_cmd("check 'hello world'") == ['check', 'hello world']
# ---------------------------------------------------------------------------
# _run_builtin_checkpatch output parsing
# ---------------------------------------------------------------------------
+
class TestBuiltinCheckpatch:
"""Tests for _run_builtin_checkpatch output parsing."""
- def _run(self, stdout: str, stderr: str = '',
- ecode: int = 0, topdir: str = '/fake') -> List[Dict[str, str]]:
+ def _run(
+ self, stdout: str, stderr: str = '', ecode: int = 0, topdir: str = '/fake'
+ ) -> List[Dict[str, str]]:
msg = _make_msg()
- with mock.patch('os.access', return_value=True), \
- mock.patch('b4._run_command', return_value=(
- ecode,
- stdout.encode() if stdout else b'',
- stderr.encode() if stderr else b'')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
+ with (
+ mock.patch('os.access', return_value=True),
+ mock.patch(
+ 'b4._run_command',
+ return_value=(
+ ecode,
+ stdout.encode() if stdout else b'',
+ stderr.encode() if stderr else b'',
+ ),
+ ),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
return checks._run_builtin_checkpatch(msg, topdir)
def test_clean_pass(self) -> None:
@@ -215,17 +235,25 @@ class TestBuiltinCheckpatch:
# _run_external_cmd JSON protocol
# ---------------------------------------------------------------------------
+
class TestRunExternalCmd:
"""Tests for _run_external_cmd JSON parsing."""
- def _run(self, stdout: str, stderr: str = '',
- ecode: int = 0) -> List[Dict[str, str]]:
+ def _run(
+ self, stdout: str, stderr: str = '', ecode: int = 0
+ ) -> List[Dict[str, str]]:
msg = _make_msg()
- with mock.patch('b4._run_command', return_value=(
- ecode,
- stdout.encode() if stdout else b'',
- stderr.encode() if stderr else b'')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
+ with (
+ mock.patch(
+ 'b4._run_command',
+ return_value=(
+ ecode,
+ stdout.encode() if stdout else b'',
+ stderr.encode() if stderr else b'',
+ ),
+ ),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
return checks._run_external_cmd(['mycheck'], msg, '/fake')
def test_valid_json_array(self) -> None:
@@ -286,16 +314,21 @@ class TestRunExternalCmd:
def test_extra_env_set_during_run(self) -> None:
captured_env: Dict[str, str] = {}
- def fake_run(cmdargs: Any, stdin: Any = None,
- rundir: Any = None) -> Any:
+ def fake_run(cmdargs: Any, stdin: Any = None, rundir: Any = None) -> Any:
captured_env['B4_TRACKING_FILE'] = os.environ.get('B4_TRACKING_FILE', '')
return (0, b'[]', b'')
msg = _make_msg()
- with mock.patch('b4._run_command', side_effect=fake_run), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
- checks._run_external_cmd(['mycheck'], msg, '/fake',
- extra_env={'B4_TRACKING_FILE': '/tmp/test.json'})
+ with (
+ mock.patch('b4._run_command', side_effect=fake_run),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
+ checks._run_external_cmd(
+ ['mycheck'],
+ msg,
+ '/fake',
+ extra_env={'B4_TRACKING_FILE': '/tmp/test.json'},
+ )
assert captured_env['B4_TRACKING_FILE'] == '/tmp/test.json'
# Env var should be cleaned up after the call
assert 'B4_TRACKING_FILE' not in os.environ
@@ -304,12 +337,17 @@ class TestRunExternalCmd:
msg = _make_msg()
os.environ['B4_TRACKING_FILE'] = 'original'
try:
- with mock.patch('b4._run_command', side_effect=RuntimeError('boom')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
+ with (
+ mock.patch('b4._run_command', side_effect=RuntimeError('boom')),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
try:
checks._run_external_cmd(
- ['mycheck'], msg, '/fake',
- extra_env={'B4_TRACKING_FILE': '/tmp/new.json'})
+ ['mycheck'],
+ msg,
+ '/fake',
+ extra_env={'B4_TRACKING_FILE': '/tmp/new.json'},
+ )
except RuntimeError:
pass
assert os.environ.get('B4_TRACKING_FILE') == 'original'
@@ -321,15 +359,18 @@ class TestRunExternalCmd:
# _run_builtin_patchwork aggregation
# ---------------------------------------------------------------------------
+
class TestBuiltinPatchwork:
"""Tests for _run_builtin_patchwork status aggregation."""
def _run(self, pw_checks: List[Dict[str, Any]]) -> List[Dict[str, str]]:
msg = _make_msg(msgid='test@example.com')
- with mock.patch('b4.LoreMessage.get_patchwork_data_by_msgid',
- return_value={'id': 42}), \
- mock.patch('b4.review.pw_fetch_checks',
- return_value=pw_checks):
+ with (
+ mock.patch(
+ 'b4.LoreMessage.get_patchwork_data_by_msgid', return_value={'id': 42}
+ ),
+ mock.patch('b4.review.pw_fetch_checks', return_value=pw_checks),
+ ):
return checks._run_builtin_patchwork(msg, 'proj', 'https://pw.example.com')
def test_all_success(self) -> None:
@@ -366,7 +407,12 @@ class TestBuiltinPatchwork:
def test_details_are_json(self) -> None:
pw = [
- {'state': 'success', 'context': 'build', 'description': 'ok', 'url': 'http://x'},
+ {
+ 'state': 'success',
+ 'context': 'build',
+ 'description': 'ok',
+ 'url': 'http://x',
+ },
]
results = self._run(pw)
details = json.loads(results[0]['details'])
@@ -381,9 +427,13 @@ class TestBuiltinPatchwork:
def test_lookup_failure_returns_empty(self) -> None:
msg = _make_msg()
- with mock.patch('b4.LoreMessage.get_patchwork_data_by_msgid',
- side_effect=LookupError('not found')):
- result = checks._run_builtin_patchwork(msg, 'proj', 'https://pw.example.com')
+ with mock.patch(
+ 'b4.LoreMessage.get_patchwork_data_by_msgid',
+ side_effect=LookupError('not found'),
+ ):
+ result = checks._run_builtin_patchwork(
+ msg, 'proj', 'https://pw.example.com'
+ )
assert result == []
@@ -391,56 +441,59 @@ class TestBuiltinPatchwork:
# High-level runners
# ---------------------------------------------------------------------------
+
class TestRunners:
"""Tests for run_perpatch_checks and run_series_checks."""
def test_perpatch_dispatches_external(self) -> None:
msg = _make_msg()
data = json.dumps([{'tool': 'ci', 'status': 'pass', 'summary': 'ok'}])
- with mock.patch('b4._run_command', return_value=(
- 0, data.encode(), b'')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
- results = checks.run_perpatch_checks(
- [('m1@ex', msg)], ['mycheck'], '/fake')
+ with (
+ mock.patch('b4._run_command', return_value=(0, data.encode(), b'')),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
+ results = checks.run_perpatch_checks([('m1@ex', msg)], ['mycheck'], '/fake')
assert 'm1@ex' in results
assert results['m1@ex'][0]['tool'] == 'ci'
def test_perpatch_exception_captured(self) -> None:
msg = _make_msg()
- with mock.patch('b4._run_command',
- side_effect=RuntimeError('boom')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
- results = checks.run_perpatch_checks(
- [('m1@ex', msg)], ['badcmd'], '/fake')
+ with (
+ mock.patch('b4._run_command', side_effect=RuntimeError('boom')),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
+ results = checks.run_perpatch_checks([('m1@ex', msg)], ['badcmd'], '/fake')
assert results['m1@ex'][0]['status'] == 'fail'
assert 'boom' in results['m1@ex'][0]['summary']
def test_series_dispatches_external(self) -> None:
msg = _make_msg()
data = json.dumps([{'tool': 'series-ci', 'status': 'warn'}])
- with mock.patch('b4._run_command', return_value=(
- 0, data.encode(), b'')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
- results = checks.run_series_checks(
- ('cover@ex', msg), ['mycheck'], '/fake')
+ with (
+ mock.patch('b4._run_command', return_value=(0, data.encode(), b'')),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
+ results = checks.run_series_checks(('cover@ex', msg), ['mycheck'], '/fake')
assert len(results) == 1
assert results[0]['tool'] == 'series-ci'
def test_series_exception_captured(self) -> None:
msg = _make_msg()
- with mock.patch('b4._run_command',
- side_effect=RuntimeError('kaboom')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
- results = checks.run_series_checks(
- ('cover@ex', msg), ['badcmd'], '/fake')
+ with (
+ mock.patch('b4._run_command', side_effect=RuntimeError('kaboom')),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
+ results = checks.run_series_checks(('cover@ex', msg), ['badcmd'], '/fake')
assert results[0]['status'] == 'fail'
assert 'kaboom' in results[0]['summary']
def test_dispatch_builtin_checkpatch(self) -> None:
msg = _make_msg()
- with mock.patch('os.access', return_value=True), \
- mock.patch('b4._run_command', return_value=(0, b'', b'')), \
- mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''):
+ with (
+ mock.patch('os.access', return_value=True),
+ mock.patch('b4._run_command', return_value=(0, b'', b'')),
+ mock.patch('b4.LoreMessage.get_msg_as_bytes', return_value=b''),
+ ):
results = checks._dispatch_cmd('_builtin_checkpatch', msg, '/fake')
assert results[0]['tool'] == 'checkpatch'
@@ -454,6 +507,7 @@ class TestRunners:
# _STATUS_ORDER module-level constant
# ---------------------------------------------------------------------------
+
class TestStatusOrder:
"""Verify the module-level status ordering constant."""
@@ -474,40 +528,72 @@ _SASHIKO_PATCHSET: Dict[str, Any] = {
'status': 'Reviewed',
'author': 'Test Author <test@example.com>',
'patches': [
- {'id': 1, 'message_id': 'patch1@example.com', 'part_index': 1,
- 'subject': '[PATCH 1/3] First patch', 'status': 'applied'},
- {'id': 2, 'message_id': 'patch2@example.com', 'part_index': 2,
- 'subject': '[PATCH 2/3] Second patch', 'status': 'applied'},
- {'id': 3, 'message_id': 'patch3@example.com', 'part_index': 3,
- 'subject': '[PATCH 3/3] Third patch', 'status': 'applied'},
+ {
+ 'id': 1,
+ 'message_id': 'patch1@example.com',
+ 'part_index': 1,
+ 'subject': '[PATCH 1/3] First patch',
+ 'status': 'applied',
+ },
+ {
+ 'id': 2,
+ 'message_id': 'patch2@example.com',
+ 'part_index': 2,
+ 'subject': '[PATCH 2/3] Second patch',
+ 'status': 'applied',
+ },
+ {
+ 'id': 3,
+ 'message_id': 'patch3@example.com',
+ 'part_index': 3,
+ 'subject': '[PATCH 3/3] Third patch',
+ 'status': 'applied',
+ },
],
'reviews': [
{
- 'id': 100, 'patch_id': 1, 'status': 'Reviewed',
+ 'id': 100,
+ 'patch_id': 1,
+ 'status': 'Reviewed',
'result': 'Review completed successfully.',
- 'summary': '', 'inline_review': 'looks good',
- 'output': json.dumps({
- 'findings': [
- {'severity': 'Low', 'problem': 'Minor style issue'},
- ],
- }),
+ 'summary': '',
+ 'inline_review': 'looks good',
+ 'output': json.dumps(
+ {
+ 'findings': [
+ {'severity': 'Low', 'problem': 'Minor style issue'},
+ ],
+ }
+ ),
},
{
- 'id': 101, 'patch_id': 2, 'status': 'Reviewed',
+ 'id': 101,
+ 'patch_id': 2,
+ 'status': 'Reviewed',
'result': 'Review completed successfully.',
- 'summary': '', 'inline_review': 'has issues',
- 'output': json.dumps({
- 'findings': [
- {'severity': 'Critical', 'problem': 'Use-after-free',
- 'suggestion': 'Add proper locking'},
- {'severity': 'High', 'problem': 'Missing error check'},
- ],
- }),
+ 'summary': '',
+ 'inline_review': 'has issues',
+ 'output': json.dumps(
+ {
+ 'findings': [
+ {
+ 'severity': 'Critical',
+ 'problem': 'Use-after-free',
+ 'suggestion': 'Add proper locking',
+ },
+ {'severity': 'High', 'problem': 'Missing error check'},
+ ],
+ }
+ ),
},
{
- 'id': 102, 'patch_id': 3, 'status': 'Skipped',
+ 'id': 102,
+ 'patch_id': 3,
+ 'status': 'Skipped',
'result': 'Skipped: touches only ignored files',
- 'summary': '', 'inline_review': '', 'output': '',
+ 'summary': '',
+ 'inline_review': '',
+ 'output': '',
},
],
}
@@ -537,7 +623,8 @@ class TestSashikoCache:
with mock.patch('b4.get_requests_session', return_value=session):
data = checks._fetch_sashiko_patchset(
- 'cover@example.com', 'https://sashiko.dev')
+ 'cover@example.com', 'https://sashiko.dev'
+ )
assert data is not None
assert data['id'] == 93
@@ -549,7 +636,8 @@ class TestSashikoCache:
# Second call should use cache, not network
session.get.reset_mock()
data2 = checks._fetch_sashiko_patchset(
- 'patch2@example.com', 'https://sashiko.dev')
+ 'patch2@example.com', 'https://sashiko.dev'
+ )
session.get.assert_not_called()
assert data2 is not None
assert data2['id'] == 93
@@ -563,19 +651,22 @@ class TestSashikoCache:
with mock.patch('b4.get_requests_session', return_value=session):
data = checks._fetch_sashiko_patchset(
- 'unknown@example.com', 'https://sashiko.dev')
+ 'unknown@example.com', 'https://sashiko.dev'
+ )
assert data is None
assert checks._sashiko_patchset_cache['unknown@example.com'] is None
def test_fetch_network_error_caches_none(self) -> None:
import requests
+
session = mock.Mock()
session.get.side_effect = requests.ConnectionError('offline')
with mock.patch('b4.get_requests_session', return_value=session):
data = checks._fetch_sashiko_patchset(
- 'test@example.com', 'https://sashiko.dev')
+ 'test@example.com', 'https://sashiko.dev'
+ )
assert data is None
assert checks._sashiko_patchset_cache['test@example.com'] is None
@@ -597,9 +688,13 @@ class TestParseSashikoFindings:
assert checks._parse_sashiko_findings({'output': 'not json'}) == []
def test_critical_finding(self) -> None:
- review = {'output': json.dumps({
- 'findings': [{'severity': 'Critical', 'problem': 'UAF bug'}],
- })}
+ review = {
+ 'output': json.dumps(
+ {
+ 'findings': [{'severity': 'Critical', 'problem': 'UAF bug'}],
+ }
+ )
+ }
findings = checks._parse_sashiko_findings(review)
assert len(findings) == 1
assert findings[0]['status'] == 'fail'
@@ -607,52 +702,79 @@ class TestParseSashikoFindings:
assert 'UAF bug' in findings[0]['description']
def test_high_finding(self) -> None:
- review = {'output': json.dumps({
- 'findings': [{'severity': 'High', 'problem': 'Missing check'}],
- })}
+ review = {
+ 'output': json.dumps(
+ {
+ 'findings': [{'severity': 'High', 'problem': 'Missing check'}],
+ }
+ )
+ }
findings = checks._parse_sashiko_findings(review)
assert findings[0]['status'] == 'fail'
assert findings[0]['state'] == 'high'
def test_medium_finding(self) -> None:
- review = {'output': json.dumps({
- 'findings': [{'severity': 'Medium', 'problem': 'Questionable logic'}],
- })}
+ review = {
+ 'output': json.dumps(
+ {
+ 'findings': [
+ {'severity': 'Medium', 'problem': 'Questionable logic'}
+ ],
+ }
+ )
+ }
findings = checks._parse_sashiko_findings(review)
assert findings[0]['status'] == 'warn'
assert findings[0]['state'] == 'medium'
def test_low_finding(self) -> None:
- review = {'output': json.dumps({
- 'findings': [{'severity': 'Low', 'problem': 'Style issue'}],
- })}
+ review = {
+ 'output': json.dumps(
+ {
+ 'findings': [{'severity': 'Low', 'problem': 'Style issue'}],
+ }
+ )
+ }
findings = checks._parse_sashiko_findings(review)
assert findings[0]['status'] == 'pass'
assert findings[0]['state'] == 'low'
def test_suggestion_appended(self) -> None:
- review = {'output': json.dumps({
- 'findings': [{'severity': 'High', 'problem': 'Bug',
- 'suggestion': 'Fix it'}],
- })}
+ review = {
+ 'output': json.dumps(
+ {
+ 'findings': [
+ {'severity': 'High', 'problem': 'Bug', 'suggestion': 'Fix it'}
+ ],
+ }
+ )
+ }
findings = checks._parse_sashiko_findings(review)
assert 'Bug' in findings[0]['description']
assert 'Fix it' in findings[0]['description']
def test_context_includes_severity(self) -> None:
- review = {'output': json.dumps({
- 'findings': [{'severity': 'Medium', 'problem': 'test'}],
- })}
+ review = {
+ 'output': json.dumps(
+ {
+ 'findings': [{'severity': 'Medium', 'problem': 'test'}],
+ }
+ )
+ }
findings = checks._parse_sashiko_findings(review)
assert findings[0]['context'] == 'sashiko/medium'
def test_multiple_findings(self) -> None:
- review = {'output': json.dumps({
- 'findings': [
- {'severity': 'Critical', 'problem': 'bad'},
- {'severity': 'Low', 'problem': 'minor'},
- ],
- })}
+ review = {
+ 'output': json.dumps(
+ {
+ 'findings': [
+ {'severity': 'Critical', 'problem': 'bad'},
+ {'severity': 'Low', 'problem': 'minor'},
+ ],
+ }
+ )
+ }
findings = checks._parse_sashiko_findings(review)
assert len(findings) == 2
@@ -670,8 +792,7 @@ class TestSashikoFindingsSummary:
assert summary == 'No findings'
def test_single_critical(self) -> None:
- findings = [{'status': 'fail', 'state': 'critical',
- 'description': 'bad'}]
+ findings = [{'status': 'fail', 'state': 'critical', 'description': 'bad'}]
worst, summary = checks._sashiko_findings_summary(findings)
assert worst == 'fail'
assert '1 critical' in summary
@@ -712,8 +833,12 @@ class TestRunBuiltinSashiko:
def _prefill_cache(self, patchset: Optional[Dict[str, Any]] = None) -> None:
"""Pre-fill the cache so no HTTP calls are made."""
ps = patchset if patchset is not None else _SASHIKO_PATCHSET
- for key in ['cover@example.com', 'patch1@example.com',
- 'patch2@example.com', 'patch3@example.com']:
+ for key in [
+ 'cover@example.com',
+ 'patch1@example.com',
+ 'patch2@example.com',
+ 'patch3@example.com',
+ ]:
checks._sashiko_patchset_cache[key] = ps
def test_no_msgid_returns_empty(self) -> None:
@@ -803,9 +928,15 @@ class TestRunBuiltinSashiko:
assert 'incomplete' in results[0]['summary'].lower()
def test_no_findings_pass(self) -> None:
- reviews = [{'id': 100, 'patch_id': 1, 'status': 'Reviewed',
- 'result': 'Review completed successfully.',
- 'output': json.dumps({'findings': []})}]
+ reviews = [
+ {
+ 'id': 100,
+ 'patch_id': 1,
+ 'status': 'Reviewed',
+ 'result': 'Review completed successfully.',
+ 'output': json.dumps({'findings': []}),
+ }
+ ]
ps = dict(_SASHIKO_PATCHSET, reviews=reviews)
self._prefill_cache(ps)
msg = _make_msg(msgid='patch1@example.com')
@@ -814,8 +945,7 @@ class TestRunBuiltinSashiko:
assert results[0]['summary'] == 'No findings'
def test_pending_review_for_patch(self) -> None:
- reviews = [{'id': 100, 'patch_id': 1, 'status': 'Pending',
- 'output': ''}]
+ reviews = [{'id': 100, 'patch_id': 1, 'status': 'Pending', 'output': ''}]
ps = dict(_SASHIKO_PATCHSET, reviews=reviews)
self._prefill_cache(ps)
msg = _make_msg(msgid='patch1@example.com')
@@ -824,8 +954,15 @@ class TestRunBuiltinSashiko:
assert 'in progress' in results[0]['summary'].lower()
def test_failed_review_for_patch(self) -> None:
- reviews = [{'id': 100, 'patch_id': 1, 'status': 'Failed',
- 'result': 'Token limit exceeded', 'output': ''}]
+ reviews = [
+ {
+ 'id': 100,
+ 'patch_id': 1,
+ 'status': 'Failed',
+ 'result': 'Token limit exceeded',
+ 'output': '',
+ }
+ ]
ps = dict(_SASHIKO_PATCHSET, reviews=reviews)
self._prefill_cache(ps)
msg = _make_msg(msgid='patch1@example.com')
@@ -863,16 +1000,20 @@ class TestSashikoAutoWire:
def test_sashiko_added_when_url_configured(self) -> None:
config = {'sashiko-url': 'https://sashiko.dev'}
- with mock.patch('b4.get_main_config', return_value=config), \
- mock.patch('b4.git_get_toplevel', return_value=None):
+ with (
+ mock.patch('b4.get_main_config', return_value=config),
+ mock.patch('b4.git_get_toplevel', return_value=None),
+ ):
perpatch, series = checks.load_check_cmds()
assert '_builtin_sashiko' in perpatch
assert '_builtin_sashiko' in series
def test_sashiko_not_added_without_url(self) -> None:
config: Dict[str, Any] = {}
- with mock.patch('b4.get_main_config', return_value=config), \
- mock.patch('b4.git_get_toplevel', return_value=None):
+ with (
+ mock.patch('b4.get_main_config', return_value=config),
+ mock.patch('b4.git_get_toplevel', return_value=None),
+ ):
perpatch, series = checks.load_check_cmds()
assert '_builtin_sashiko' not in perpatch
assert '_builtin_sashiko' not in series
@@ -883,8 +1024,10 @@ class TestSashikoAutoWire:
'review-perpatch-check-cmd': ['_builtin_sashiko'],
'review-series-check-cmd': ['_builtin_sashiko'],
}
- with mock.patch('b4.get_main_config', return_value=config), \
- mock.patch('b4.git_get_toplevel', return_value=None):
+ with (
+ mock.patch('b4.get_main_config', return_value=config),
+ mock.patch('b4.git_get_toplevel', return_value=None),
+ ):
perpatch, series = checks.load_check_cmds()
assert perpatch.count('_builtin_sashiko') == 1
assert series.count('_builtin_sashiko') == 1
@@ -899,7 +1042,8 @@ class TestSashikoDispatch:
msg = _make_msg(msgid='test@ex')
# Pre-cache so no HTTP call is made; use cover msgid
checks._sashiko_patchset_cache['test@ex'] = dict(
- _SASHIKO_PATCHSET, message_id='test@ex')
+ _SASHIKO_PATCHSET, message_id='test@ex'
+ )
config = {'sashiko-url': 'https://sashiko.dev'}
with mock.patch('b4.get_main_config', return_value=config):
results = checks._dispatch_cmd('_builtin_sashiko', msg, '/fake')
diff --git a/src/tests/test_review_show_info.py b/src/tests/test_review_show_info.py
index db955c4..92dfaba 100644
--- a/src/tests/test_review_show_info.py
+++ b/src/tests/test_review_show_info.py
@@ -4,6 +4,7 @@
# Copyright (C) 2024 by the Linux Foundation
#
"""Tests for ``b4 review show-info``."""
+
import json
import pytest
@@ -20,15 +21,19 @@ from b4.review._review import (
# Helpers
# ---------------------------------------------------------------------------
-def _create_review_branch(gitdir: str, change_id: str,
- identifier: str = 'test-project',
- revision: int = 1,
- status: str = 'reviewing',
- subject: str = 'Test series',
- sender_name: str = 'Test Author',
- sender_email: str = 'test@example.com',
- link: str = '',
- num_real_commits: int = 0) -> str:
+
+def _create_review_branch(
+ gitdir: str,
+ change_id: str,
+ identifier: str = 'test-project',
+ revision: int = 1,
+ status: str = 'reviewing',
+ subject: str = 'Test series',
+ sender_name: str = 'Test Author',
+ sender_email: str = 'test@example.com',
+ link: str = '',
+ num_real_commits: int = 0,
+) -> str:
"""Create a fake b4 review branch with a proper tracking commit.
When *num_real_commits* > 0, that many empty commits are created between
@@ -54,7 +59,9 @@ def _create_review_branch(gitdir: str, change_id: str,
first_patch_commit = None
for i in range(num_real_commits):
ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', f'patch {i+1}: do thing {i+1}'])
+ gitdir,
+ ['commit', '--allow-empty', '-m', f'patch {i + 1}: do thing {i + 1}'],
+ )
assert ecode == 0
if i == 0:
ecode, sha = b4.git_run_command(gitdir, ['rev-parse', 'HEAD'])
@@ -88,8 +95,7 @@ def _create_review_branch(gitdir: str, change_id: str,
commit_msg = f'{subject}\n\n{b4.review.make_review_magic_json(trk)}'
# Create the tracking commit
- ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', commit_msg])
+ ecode, _ = b4.git_run_command(gitdir, ['commit', '--allow-empty', '-m', commit_msg])
assert ecode == 0
# Go back to master
@@ -103,12 +109,12 @@ def _create_review_branch(gitdir: str, change_id: str,
# TestGetReviewInfo
# ---------------------------------------------------------------------------
-class TestGetReviewInfo:
+class TestGetReviewInfo:
def test_basic_info(self, gitdir: str) -> None:
- branch = _create_review_branch(gitdir, 'basic-change-id',
- subject='Basic test series',
- status='reviewing')
+ branch = _create_review_branch(
+ gitdir, 'basic-change-id', subject='Basic test series', status='reviewing'
+ )
info = get_review_info(gitdir, branch)
assert info['branch'] == branch
@@ -122,15 +128,17 @@ class TestGetReviewInfo:
assert info['first-patch-commit'] is not None
def test_sender_format(self, gitdir: str) -> None:
- branch = _create_review_branch(gitdir, 'sender-test',
- sender_name='Alice Author',
- sender_email='alice@example.com')
+ branch = _create_review_branch(
+ gitdir,
+ 'sender-test',
+ sender_name='Alice Author',
+ sender_email='alice@example.com',
+ )
info = get_review_info(gitdir, branch)
assert info['sender'] == 'Alice Author <alice@example.com>'
def test_commit_keys(self, gitdir: str) -> None:
- branch = _create_review_branch(gitdir, 'commit-keys-test',
- num_real_commits=3)
+ branch = _create_review_branch(gitdir, 'commit-keys-test', num_real_commits=3)
info = get_review_info(gitdir, branch)
assert info['num-patches'] == 3
@@ -147,8 +155,8 @@ class TestGetReviewInfo:
# TestShowReviewInfo
# ---------------------------------------------------------------------------
-class TestShowReviewInfo:
+class TestShowReviewInfo:
def test_all_keys(self, gitdir: str, capsys: pytest.CaptureFixture[str]) -> None:
_create_review_branch(gitdir, 'show-all-test', subject='All keys test')
show_review_info('b4/review/show-all-test:_all')
@@ -163,7 +171,9 @@ class TestShowReviewInfo:
out = capsys.readouterr().out
assert out.strip() == 'applied'
- def test_named_branch(self, gitdir: str, capsys: pytest.CaptureFixture[str]) -> None:
+ def test_named_branch(
+ self, gitdir: str, capsys: pytest.CaptureFixture[str]
+ ) -> None:
branch = _create_review_branch(gitdir, 'named-branch-test')
show_review_info(branch)
out = capsys.readouterr().out
@@ -196,9 +206,11 @@ class TestShowReviewInfo:
# TestListReviewBranches
# ---------------------------------------------------------------------------
-class TestListReviewBranches:
- def test_list_multiple(self, gitdir: str, capsys: pytest.CaptureFixture[str]) -> None:
+class TestListReviewBranches:
+ def test_list_multiple(
+ self, gitdir: str, capsys: pytest.CaptureFixture[str]
+ ) -> None:
_create_review_branch(gitdir, 'list-alpha', subject='Alpha series')
_create_review_branch(gitdir, 'list-bravo', subject='Bravo series')
list_review_branches()
@@ -229,8 +241,8 @@ class TestListReviewBranches:
# TestTargetBranchInInfo
# ---------------------------------------------------------------------------
-class TestTargetBranchInInfo:
+class TestTargetBranchInInfo:
def test_target_branch_in_info(self, gitdir: str) -> None:
"""Branch with target-branch in tracking data includes it in info."""
branch_name = 'b4/review/target-info-test'
@@ -264,14 +276,19 @@ class TestTargetBranchInInfo:
'patches': [],
}
commit_msg = f'Target info test\n\n{b4.review.make_review_magic_json(trk)}'
- ecode, tree = b4.git_run_command(gitdir, ['rev-parse', f'{branch_name}^{{tree}}'])
+ ecode, tree = b4.git_run_command(
+ gitdir, ['rev-parse', f'{branch_name}^{{tree}}']
+ )
assert ecode == 0
ecode, new_sha = b4.git_run_command(
- gitdir, ['commit-tree', tree.strip(), '-p', base_sha],
- stdin=commit_msg.encode())
+ gitdir,
+ ['commit-tree', tree.strip(), '-p', base_sha],
+ stdin=commit_msg.encode(),
+ )
assert ecode == 0
ecode, _ = b4.git_run_command(
- gitdir, ['update-ref', f'refs/heads/{branch_name}', new_sha.strip()])
+ gitdir, ['update-ref', f'refs/heads/{branch_name}', new_sha.strip()]
+ )
assert ecode == 0
info = get_review_info(gitdir, branch_name)
@@ -280,16 +297,19 @@ class TestTargetBranchInInfo:
def test_target_branch_fallback(self, gitdir: str) -> None:
"""No per-series target + single config value = fallback shown."""
from unittest.mock import patch as mock_patch
- branch = _create_review_branch(gitdir, 'target-fallback-test',
- subject='Fallback test')
- with mock_patch('b4.review.tracking.get_review_target_branch_default',
- return_value='regulator/for-next'):
+
+ branch = _create_review_branch(
+ gitdir, 'target-fallback-test', subject='Fallback test'
+ )
+ with mock_patch(
+ 'b4.review.tracking.get_review_target_branch_default',
+ return_value='regulator/for-next',
+ ):
info = get_review_info(gitdir, branch)
assert info['target-branch'] == 'regulator/for-next'
def test_target_branch_none(self, gitdir: str) -> None:
"""No per-series target + no config = None."""
- branch = _create_review_branch(gitdir, 'target-none-test',
- subject='None test')
+ branch = _create_review_branch(gitdir, 'target-none-test', subject='None test')
info = get_review_info(gitdir, branch)
assert info['target-branch'] is None
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index a5fd903..e106312 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -71,19 +71,23 @@ class TestDbOperations:
sender_email='author@example.com',
sent_at='2024-01-15T10:00:00+00:00',
message_id='test-msgid@example.com',
- num_patches=3
+ num_patches=3,
)
assert track_id == 1
- cursor = conn.execute('SELECT track_id, change_id, subject FROM series WHERE change_id = ?',
- ('test-change-id',))
+ cursor = conn.execute(
+ 'SELECT track_id, change_id, subject FROM series WHERE change_id = ?',
+ ('test-change-id',),
+ )
row = cursor.fetchone()
assert row is not None
assert row[0] == track_id
assert row[2] == 'Test series subject'
conn.close()
- def test_add_series_with_pw_series_id(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_add_series_with_pw_series_id(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify series can be added with patchwork series ID."""
conn = review_tracking.init_db('pw-series-test')
track_id = review_tracking.add_series_to_db(
@@ -96,27 +100,45 @@ class TestDbOperations:
sent_at='2024-01-15T10:00:00+00:00',
message_id='test-msgid@example.com',
num_patches=3,
- pw_series_id=12345
+ pw_series_id=12345,
)
- cursor = conn.execute('SELECT pw_series_id FROM series WHERE track_id = ?', (track_id,))
+ cursor = conn.execute(
+ 'SELECT pw_series_id FROM series WHERE track_id = ?', (track_id,)
+ )
row = cursor.fetchone()
assert row[0] == 12345
conn.close()
- def test_add_series_multiple_revisions(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_add_series_multiple_revisions(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify multiple revisions can be tracked for the same change-id."""
conn = review_tracking.init_db('multi-rev-test')
# Add v1
track_id_v1 = review_tracking.add_series_to_db(
- conn, 'change-123', 1, 'Subject v1', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid-v1@example.com', 3
+ conn,
+ 'change-123',
+ 1,
+ 'Subject v1',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid-v1@example.com',
+ 3,
)
# Add v2
track_id_v2 = review_tracking.add_series_to_db(
- conn, 'change-123', 2, 'Subject v2', 'Author', 'a@example.com',
- '2024-01-16T10:00:00+00:00', 'msgid-v2@example.com', 4
+ conn,
+ 'change-123',
+ 2,
+ 'Subject v2',
+ 'Author',
+ 'a@example.com',
+ '2024-01-16T10:00:00+00:00',
+ 'msgid-v2@example.com',
+ 4,
)
# Different track_ids
@@ -124,7 +146,7 @@ class TestDbOperations:
cursor = conn.execute(
'SELECT track_id, revision, num_patches FROM series WHERE change_id = ? ORDER BY revision',
- ('change-123',)
+ ('change-123',),
)
rows = cursor.fetchall()
assert len(rows) == 2
@@ -137,12 +159,26 @@ class TestDbOperations:
conn = review_tracking.init_db('upsert-test')
track_id_1 = review_tracking.add_series_to_db(
- conn, 'change-456', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid-old@example.com', 3
+ conn,
+ 'change-456',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid-old@example.com',
+ 3,
)
track_id_2 = review_tracking.add_series_to_db(
- conn, 'change-456', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid-new@example.com', 5
+ conn,
+ 'change-456',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid-new@example.com',
+ 5,
)
# Same track_id after upsert
@@ -150,7 +186,7 @@ class TestDbOperations:
cursor = conn.execute(
'SELECT track_id, message_id, num_patches FROM series WHERE change_id = ? AND revision = ?',
- ('change-456', 1)
+ ('change-456', 1),
)
row = cursor.fetchone()
assert row == (track_id_1, 'msgid-new@example.com', 5)
@@ -161,19 +197,40 @@ class TestDbOperations:
conn = review_tracking.init_db('pw-ids-test')
# Add series with pw_series_id
review_tracking.add_series_to_db(
- conn, 'change-1', 1, 'Subject 1', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3,
- pw_series_id=100
+ conn,
+ 'change-1',
+ 1,
+ 'Subject 1',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
+ pw_series_id=100,
)
review_tracking.add_series_to_db(
- conn, 'change-2', 1, 'Subject 2', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3,
- pw_series_id=200
+ conn,
+ 'change-2',
+ 1,
+ 'Subject 2',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
+ pw_series_id=200,
)
# Add series without pw_series_id
review_tracking.add_series_to_db(
- conn, 'change-3', 1, 'Subject 3', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3
+ conn,
+ 'change-3',
+ 1,
+ 'Subject 3',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
)
conn.close()
@@ -191,9 +248,16 @@ class TestDbOperations:
"""Verify is_pw_series_tracked works correctly."""
conn = review_tracking.init_db('is-tracked-test')
review_tracking.add_series_to_db(
- conn, 'change-1', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3,
- pw_series_id=12345
+ conn,
+ 'change-1',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
+ pw_series_id=12345,
)
conn.close()
@@ -210,12 +274,26 @@ class TestDbOperations:
"""Verify get_all_tracked_series returns all series with correct fields."""
conn = review_tracking.init_db('all-series-test')
review_tracking.add_series_to_db(
- conn, 'change-1', 1, 'First series', 'Author One', 'one@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid-1@example.com', 3
+ conn,
+ 'change-1',
+ 1,
+ 'First series',
+ 'Author One',
+ 'one@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid-1@example.com',
+ 3,
)
review_tracking.add_series_to_db(
- conn, 'change-2', 2, 'Second series', 'Author Two', 'two@example.com',
- '2024-01-16T10:00:00+00:00', 'msgid-2@example.com', 5
+ conn,
+ 'change-2',
+ 2,
+ 'Second series',
+ 'Author Two',
+ 'two@example.com',
+ '2024-01-16T10:00:00+00:00',
+ 'msgid-2@example.com',
+ 5,
)
conn.close()
@@ -264,7 +342,9 @@ class TestRepoMetadata:
# Create a real worktree
worktree_dir = os.path.join(str(os.path.dirname(gitdir)), 'worktree')
- out, _logstr = b4.git_run_command(gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch'])
+ out, _logstr = b4.git_run_command(
+ gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch']
+ )
assert out == 0
identifier = review_tracking.get_repo_identifier(worktree_dir)
@@ -293,7 +373,9 @@ class TestResolveIdentifier:
result = review_tracking.resolve_identifier(cmdargs, gitdir)
assert result == 'repo-identifier'
- def test_returns_none_when_no_identifier(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_returns_none_when_no_identifier(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify returns None when no identifier available."""
cmdargs = argparse.Namespace(identifier=None)
# Pass a non-git directory
@@ -306,20 +388,14 @@ class TestCmdEnroll:
def test_enroll_creates_database(self, gitdir: str) -> None:
"""Verify enroll creates the database."""
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier='enroll-test'
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier='enroll-test')
review_tracking.cmd_enroll(cmdargs)
assert review_tracking.db_exists('enroll-test')
def test_enroll_creates_metadata_file(self, gitdir: str) -> None:
"""Verify enroll creates metadata file in .git directory."""
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier='metadata-test'
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier='metadata-test')
review_tracking.cmd_enroll(cmdargs)
metadata_path = os.path.join(gitdir, '.git', 'b4-review', 'metadata.json')
@@ -327,10 +403,7 @@ class TestCmdEnroll:
def test_enroll_uses_dirname_as_default_identifier(self, gitdir: str) -> None:
"""Verify enroll uses directory name as default identifier."""
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier=None
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier=None)
review_tracking.cmd_enroll(cmdargs)
dirname = os.path.basename(gitdir)
@@ -339,10 +412,7 @@ class TestCmdEnroll:
def test_enroll_uses_current_directory_when_no_path(self, gitdir: str) -> None:
"""Verify enroll uses current directory when no path specified."""
# gitdir fixture already changes cwd to the test repo
- cmdargs = argparse.Namespace(
- repo_path=None,
- identifier='current-dir-test'
- )
+ cmdargs = argparse.Namespace(repo_path=None, identifier='current-dir-test')
review_tracking.cmd_enroll(cmdargs)
assert review_tracking.db_exists('current-dir-test')
@@ -359,10 +429,7 @@ class TestCmdEnroll:
oldcwd = os.getcwd()
os.chdir(non_git_dir)
try:
- cmdargs = argparse.Namespace(
- repo_path=None,
- identifier='test'
- )
+ cmdargs = argparse.Namespace(repo_path=None, identifier='test')
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_enroll(cmdargs)
assert exc_info.value.code == 1
@@ -373,10 +440,7 @@ class TestCmdEnroll:
self, tmp_path: pytest.TempPathFactory
) -> None:
"""Verify enroll fails for non-existent paths."""
- cmdargs = argparse.Namespace(
- repo_path='/nonexistent/path',
- identifier='test'
- )
+ cmdargs = argparse.Namespace(repo_path='/nonexistent/path', identifier='test')
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_enroll(cmdargs)
assert exc_info.value.code == 1
@@ -388,10 +452,7 @@ class TestCmdEnroll:
non_git_dir = os.path.join(str(tmp_path), 'not-a-repo')
os.makedirs(non_git_dir)
- cmdargs = argparse.Namespace(
- repo_path=non_git_dir,
- identifier='test'
- )
+ cmdargs = argparse.Namespace(repo_path=non_git_dir, identifier='test')
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_enroll(cmdargs)
assert exc_info.value.code == 1
@@ -399,17 +460,11 @@ class TestCmdEnroll:
def test_enroll_fails_when_repo_already_enrolled(self, gitdir: str) -> None:
"""Verify enroll fails when repository already has metadata."""
# First enrollment
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier='first-id'
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier='first-id')
review_tracking.cmd_enroll(cmdargs)
# Second enrollment of same repo should fail
- cmdargs2 = argparse.Namespace(
- repo_path=gitdir,
- identifier='second-id'
- )
+ cmdargs2 = argparse.Namespace(repo_path=gitdir, identifier='second-id')
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_enroll(cmdargs2)
assert exc_info.value.code == 1
@@ -420,10 +475,7 @@ class TestCmdEnroll:
) -> None:
"""Verify enroll can reuse existing database for different repo."""
# Create database via first enrollment
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier='shared-db'
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier='shared-db')
review_tracking.cmd_enroll(cmdargs)
# Create a second git repo
@@ -431,10 +483,7 @@ class TestCmdEnroll:
b4.git_run_command(None, ['init', second_repo])
# Enroll second repo with same identifier - user confirms
- cmdargs2 = argparse.Namespace(
- repo_path=second_repo,
- identifier='shared-db'
- )
+ cmdargs2 = argparse.Namespace(repo_path=second_repo, identifier='shared-db')
review_tracking.cmd_enroll(cmdargs2)
# Metadata file should exist in second repo's .git
@@ -448,10 +497,7 @@ class TestCmdEnroll:
) -> None:
"""Verify enroll aborts when user declines to use existing database."""
# Create database via first enrollment
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier='declined-db'
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier='declined-db')
review_tracking.cmd_enroll(cmdargs)
# Create a second git repo
@@ -459,10 +505,7 @@ class TestCmdEnroll:
b4.git_run_command(None, ['init', second_repo])
# Enroll second repo with same identifier - user declines
- cmdargs2 = argparse.Namespace(
- repo_path=second_repo,
- identifier='declined-db'
- )
+ cmdargs2 = argparse.Namespace(repo_path=second_repo, identifier='declined-db')
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_enroll(cmdargs2)
# Exit code 0 for user-initiated cancellation
@@ -478,13 +521,12 @@ class TestCmdEnroll:
"""Verify enroll from a worktree writes metadata to the shared .git."""
# Create a real worktree
worktree_dir = os.path.join(str(os.path.dirname(gitdir)), 'worktree')
- out, _logstr = b4.git_run_command(gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch'])
+ out, _logstr = b4.git_run_command(
+ gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch']
+ )
assert out == 0
- cmdargs = argparse.Namespace(
- repo_path=worktree_dir,
- identifier='worktree-test'
- )
+ cmdargs = argparse.Namespace(repo_path=worktree_dir, identifier='worktree-test')
review_tracking.cmd_enroll(cmdargs)
# Database should be created
@@ -496,22 +538,18 @@ class TestCmdEnroll:
def test_enroll_from_worktree_already_enrolled(self, gitdir: str) -> None:
"""Verify enrolling from worktree exits 0 when repo already enrolled."""
# Enroll the main repo first
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier='main-id'
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier='main-id')
review_tracking.cmd_enroll(cmdargs)
# Create a real worktree
worktree_dir = os.path.join(str(os.path.dirname(gitdir)), 'worktree')
- out, _logstr = b4.git_run_command(gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch'])
+ out, _logstr = b4.git_run_command(
+ gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch']
+ )
assert out == 0
# Enrolling from worktree with same identifier should exit 0
- cmdargs2 = argparse.Namespace(
- repo_path=worktree_dir,
- identifier='main-id'
- )
+ cmdargs2 = argparse.Namespace(repo_path=worktree_dir, identifier='main-id')
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_enroll(cmdargs2)
assert exc_info.value.code == 0
@@ -519,22 +557,18 @@ class TestCmdEnroll:
def test_enroll_from_worktree_conflicting_identifier(self, gitdir: str) -> None:
"""Verify enrolling from worktree fails with a different identifier."""
# Enroll the main repo first
- cmdargs = argparse.Namespace(
- repo_path=gitdir,
- identifier='main-id'
- )
+ cmdargs = argparse.Namespace(repo_path=gitdir, identifier='main-id')
review_tracking.cmd_enroll(cmdargs)
# Create a real worktree
worktree_dir = os.path.join(str(os.path.dirname(gitdir)), 'worktree')
- out, _logstr = b4.git_run_command(gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch'])
+ out, _logstr = b4.git_run_command(
+ gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch']
+ )
assert out == 0
# Enrolling from worktree with different identifier should fail
- cmdargs2 = argparse.Namespace(
- repo_path=worktree_dir,
- identifier='different-id'
- )
+ cmdargs2 = argparse.Namespace(repo_path=worktree_dir, identifier='different-id')
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_enroll(cmdargs2)
assert exc_info.value.code == 1
@@ -549,7 +583,9 @@ class TestCmdTrack:
fromname: str = 'Test Author',
fromemail: str = 'author@example.com',
subject: str = 'Test patch',
- date: datetime.datetime = datetime.datetime(2024, 1, 15, 10, 0, 0, tzinfo=datetime.timezone.utc)
+ date: datetime.datetime = datetime.datetime(
+ 2024, 1, 15, 10, 0, 0, tzinfo=datetime.timezone.utc
+ ),
) -> mock.Mock:
"""Create a mock LoreMessage."""
lmsg = mock.Mock()
@@ -571,7 +607,7 @@ class TestCmdTrack:
first_patch_msgid: str = 'patch1@example.com',
fromname: str = 'Test Author',
fromemail: str = 'author@example.com',
- subject: str = 'Test series'
+ subject: str = 'Test series',
) -> mock.Mock:
"""Create a mock LoreSeries."""
lser = mock.Mock()
@@ -594,10 +630,7 @@ class TestCmdTrack:
@mock.patch('b4.retrieve_messages')
@mock.patch('b4.LoreMailbox')
def test_track_with_change_id(
- self,
- mock_mailbox_class: mock.Mock,
- mock_retrieve: mock.Mock,
- gitdir: str
+ self, mock_mailbox_class: mock.Mock, mock_retrieve: mock.Mock, gitdir: str
) -> None:
"""Verify tracking a series with a change-id."""
# Set up enrolled project
@@ -620,7 +653,7 @@ class TestCmdTrack:
msgid=None,
noparent=False,
wantname=None,
- wantver=None
+ wantver=None,
)
review_tracking.cmd_track(cmdargs)
@@ -635,10 +668,7 @@ class TestCmdTrack:
@mock.patch('b4.retrieve_messages')
@mock.patch('b4.LoreMailbox')
def test_track_generates_change_id_without_change_id(
- self,
- mock_mailbox_class: mock.Mock,
- mock_retrieve: mock.Mock,
- gitdir: str
+ self, mock_mailbox_class: mock.Mock, mock_retrieve: mock.Mock, gitdir: str
) -> None:
"""Verify tracking generates a change-id when series has none."""
cmdargs_enroll = argparse.Namespace(repo_path=gitdir, identifier='noid-test')
@@ -659,7 +689,7 @@ class TestCmdTrack:
msgid=None,
noparent=False,
wantname=None,
- wantver=None
+ wantver=None,
)
review_tracking.cmd_track(cmdargs)
@@ -675,21 +705,19 @@ class TestCmdTrack:
@mock.patch('b4.retrieve_messages')
@mock.patch('b4.LoreMailbox')
def test_track_uses_first_patch_without_cover(
- self,
- mock_mailbox_class: mock.Mock,
- mock_retrieve: mock.Mock,
- gitdir: str
+ self, mock_mailbox_class: mock.Mock, mock_retrieve: mock.Mock, gitdir: str
) -> None:
"""Verify tracking uses first patch msgid when no cover letter."""
- cmdargs_enroll = argparse.Namespace(repo_path=gitdir, identifier='no-cover-test')
+ cmdargs_enroll = argparse.Namespace(
+ repo_path=gitdir, identifier='no-cover-test'
+ )
review_tracking.cmd_enroll(cmdargs_enroll)
mock_msg = mock.Mock()
mock_retrieve.return_value = ('test-msgid', [mock_msg])
mock_lser = self._make_mock_lore_series(
- has_cover=False,
- first_patch_msgid='first-patch@example.com'
+ has_cover=False, first_patch_msgid='first-patch@example.com'
)
mock_mailbox = mock.Mock()
mock_mailbox.series = {1: mock_lser}
@@ -702,7 +730,7 @@ class TestCmdTrack:
msgid=None,
noparent=False,
wantname=None,
- wantver=None
+ wantver=None,
)
review_tracking.cmd_track(cmdargs)
@@ -714,9 +742,7 @@ class TestCmdTrack:
@mock.patch('b4.review.tracking.resolve_identifier', return_value=None)
def test_track_fails_without_identifier(
- self,
- mock_resolve: mock.Mock,
- tmp_path: pytest.TempPathFactory
+ self, mock_resolve: mock.Mock, tmp_path: pytest.TempPathFactory
) -> None:
"""Verify track fails when no identifier can be resolved."""
cmdargs = argparse.Namespace(
@@ -725,7 +751,7 @@ class TestCmdTrack:
msgid=None,
noparent=False,
wantname=None,
- wantver=None
+ wantver=None,
)
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_track(cmdargs)
@@ -733,9 +759,7 @@ class TestCmdTrack:
@mock.patch('b4.retrieve_messages')
def test_track_fails_for_unenrolled_project(
- self,
- mock_retrieve: mock.Mock,
- tmp_path: pytest.TempPathFactory
+ self, mock_retrieve: mock.Mock, tmp_path: pytest.TempPathFactory
) -> None:
"""Verify track fails when project is not enrolled."""
cmdargs = argparse.Namespace(
@@ -744,7 +768,7 @@ class TestCmdTrack:
msgid=None,
noparent=False,
wantname=None,
- wantver=None
+ wantver=None,
)
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_track(cmdargs)
@@ -752,12 +776,12 @@ class TestCmdTrack:
@mock.patch('b4.retrieve_messages')
def test_track_fails_when_retrieval_fails(
- self,
- mock_retrieve: mock.Mock,
- gitdir: str
+ self, mock_retrieve: mock.Mock, gitdir: str
) -> None:
"""Verify track fails when series retrieval fails."""
- cmdargs_enroll = argparse.Namespace(repo_path=gitdir, identifier='retrieval-fail')
+ cmdargs_enroll = argparse.Namespace(
+ repo_path=gitdir, identifier='retrieval-fail'
+ )
review_tracking.cmd_enroll(cmdargs_enroll)
mock_retrieve.return_value = (None, None)
@@ -768,7 +792,7 @@ class TestCmdTrack:
msgid=None,
noparent=False,
wantname=None,
- wantver=None
+ wantver=None,
)
with pytest.raises(SystemExit) as exc_info:
review_tracking.cmd_track(cmdargs)
@@ -781,8 +805,14 @@ class TestRevisions:
def test_add_revision(self, tmp_path: pytest.TempPathFactory) -> None:
"""Verify a revision can be added and retrieved."""
conn = review_tracking.init_db('rev-add-test')
- review_tracking.add_revision(conn, 'change-abc', 1, 'msgid-v1@example.com',
- subject='Test v1', link='https://lore.kernel.org/r/msgid-v1')
+ review_tracking.add_revision(
+ conn,
+ 'change-abc',
+ 1,
+ 'msgid-v1@example.com',
+ subject='Test v1',
+ link='https://lore.kernel.org/r/msgid-v1',
+ )
revs = review_tracking.get_revisions(conn, 'change-abc')
assert len(revs) == 1
assert revs[0]['change_id'] == 'change-abc'
@@ -840,7 +870,9 @@ class TestRevisions:
assert result == {'change-a': 3, 'change-b': 2}
conn.close()
- def test_get_all_newest_revisions_empty(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_get_all_newest_revisions_empty(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify bulk newest-revision query returns empty dict with no data."""
conn = review_tracking.init_db('rev-bulk-newest-empty-test')
assert review_tracking.get_all_newest_revisions(conn) == {}
@@ -860,9 +892,15 @@ class TestRevisions:
def test_get_all_revisions_grouped(self, tmp_path: pytest.TempPathFactory) -> None:
"""Verify bulk grouped revisions returns correct per-change-id lists."""
conn = review_tracking.init_db('rev-bulk-grouped-test')
- review_tracking.add_revision(conn, 'change-a', 2, 'a-v2@example.com', subject='A v2')
- review_tracking.add_revision(conn, 'change-a', 1, 'a-v1@example.com', subject='A v1')
- review_tracking.add_revision(conn, 'change-b', 1, 'b-v1@example.com', subject='B v1')
+ review_tracking.add_revision(
+ conn, 'change-a', 2, 'a-v2@example.com', subject='A v2'
+ )
+ review_tracking.add_revision(
+ conn, 'change-a', 1, 'a-v1@example.com', subject='A v1'
+ )
+ review_tracking.add_revision(
+ conn, 'change-b', 1, 'b-v1@example.com', subject='B v1'
+ )
result = review_tracking.get_all_revisions_grouped(conn)
assert set(result.keys()) == {'change-a', 'change-b'}
# change-a should be sorted ascending
@@ -870,7 +908,9 @@ class TestRevisions:
assert len(result['change-b']) == 1
conn.close()
- def test_get_all_revisions_grouped_empty(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_get_all_revisions_grouped_empty(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify bulk grouped revisions returns empty dict with no data."""
conn = review_tracking.init_db('rev-bulk-grouped-empty-test')
assert review_tracking.get_all_revisions_grouped(conn) == {}
@@ -881,48 +921,78 @@ class TestRevisions:
conn = review_tracking.init_db('del-series-test')
# Add a series with revisions
review_tracking.add_series_to_db(
- conn, 'change-del', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3)
+ conn,
+ 'change-del',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
+ )
review_tracking.add_revision(conn, 'change-del', 1, 'msgid-v1@example.com')
review_tracking.add_revision(conn, 'change-del', 2, 'msgid-v2@example.com')
# Add another series that should not be affected
review_tracking.add_series_to_db(
- conn, 'change-keep', 1, 'Keep', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'keep@example.com', 1)
+ conn,
+ 'change-keep',
+ 1,
+ 'Keep',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'keep@example.com',
+ 1,
+ )
review_tracking.add_revision(conn, 'change-keep', 1, 'keep-v1@example.com')
review_tracking.delete_series(conn, 'change-del')
# Deleted change_id should be gone from both tables
- cursor = conn.execute('SELECT * FROM series WHERE change_id = ?',
- ('change-del',))
+ cursor = conn.execute(
+ 'SELECT * FROM series WHERE change_id = ?', ('change-del',)
+ )
assert cursor.fetchone() is None
assert review_tracking.get_revisions(conn, 'change-del') == []
# Other change_id should be untouched
- cursor = conn.execute('SELECT * FROM series WHERE change_id = ?',
- ('change-keep',))
+ cursor = conn.execute(
+ 'SELECT * FROM series WHERE change_id = ?', ('change-keep',)
+ )
assert cursor.fetchone() is not None
assert len(review_tracking.get_revisions(conn, 'change-keep')) == 1
conn.close()
+
class TestUpdateSeriesStatus:
"""Tests for update_series_status()."""
def test_updates_existing_series(self, tmp_path: pytest.TempPathFactory) -> None:
conn = review_tracking.init_db('status-update-test')
review_tracking.add_series_to_db(
- conn, 'change-status', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3)
+ conn,
+ 'change-status',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
+ )
review_tracking.update_series_status(conn, 'change-status', 'reviewing')
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?', ('change-status',))
+ 'SELECT status FROM series WHERE change_id = ?', ('change-status',)
+ )
assert cursor.fetchone()[0] == 'reviewing'
conn.close()
- def test_noop_for_nonexistent_change_id(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_noop_for_nonexistent_change_id(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
conn = review_tracking.init_db('status-noop-test')
# Should not raise
review_tracking.update_series_status(conn, 'nonexistent', 'reviewing')
@@ -942,7 +1012,9 @@ class TestGitGetCommonDir:
def test_returns_shared_git_dir_from_worktree(self, gitdir: str) -> None:
"""Verify git_get_common_dir returns the shared .git from a worktree."""
worktree_dir = os.path.join(str(os.path.dirname(gitdir)), 'worktree')
- out, _logstr = b4.git_run_command(gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch'])
+ out, _logstr = b4.git_run_command(
+ gitdir, ['worktree', 'add', worktree_dir, '-b', 'wt-branch']
+ )
assert out == 0
result = b4.git_get_common_dir(worktree_dir)
@@ -950,7 +1022,9 @@ class TestGitGetCommonDir:
expected = os.path.join(gitdir, '.git')
assert os.path.normpath(result) == os.path.normpath(expected)
- def test_returns_none_for_non_git_dir(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_returns_none_for_non_git_dir(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify git_get_common_dir returns None outside a git repo."""
non_git = os.path.join(str(tmp_path), 'not-a-repo')
os.makedirs(non_git)
@@ -967,12 +1041,13 @@ class TestReviewTargetBranch:
assert b4.DEFAULT_CONFIG['review-target-branch'] is None
-def _create_review_branch(topdir: str, change_id: str, tracking_data: Dict[str, Any]) -> str:
+def _create_review_branch(
+ topdir: str, change_id: str, tracking_data: Dict[str, Any]
+) -> str:
"""Helper: create a b4/review/<change_id> branch with a tracking commit."""
branch = f'b4/review/{change_id}'
cover_text = f'Cover letter for {change_id}'
- commit_msg = (cover_text + '\n\n'
- + b4.review.make_review_magic_json(tracking_data))
+ commit_msg = cover_text + '\n\n' + b4.review.make_review_magic_json(tracking_data)
# Create an orphan-ish branch off current HEAD
b4.git_run_command(topdir, ['branch', branch])
# Create a tracking commit on it via commit-tree
@@ -983,11 +1058,15 @@ def _create_review_branch(topdir: str, change_id: str, tracking_data: Dict[str,
assert ecode == 0
parent = parent.strip()
ecode, new_sha = b4.git_run_command(
- topdir, ['commit-tree', tree, '-p', parent, '-F', '-'],
- stdin=commit_msg.encode())
+ topdir,
+ ['commit-tree', tree, '-p', parent, '-F', '-'],
+ stdin=commit_msg.encode(),
+ )
assert ecode == 0
new_sha = new_sha.strip()
- ecode, _ = b4.git_run_command(topdir, ['update-ref', f'refs/heads/{branch}', new_sha])
+ ecode, _ = b4.git_run_command(
+ topdir, ['update-ref', f'refs/heads/{branch}', new_sha]
+ )
assert ecode == 0
return branch
@@ -1057,7 +1136,9 @@ class TestUpdateTrackingStatus:
def test_returns_false_for_missing_branch(self, gitdir: str) -> None:
"""Verify update_tracking_status returns False for non-existent branch."""
- result = b4.review.update_tracking_status(gitdir, 'b4/review/nonexistent', 'replied')
+ result = b4.review.update_tracking_status(
+ gitdir, 'b4/review/nonexistent', 'replied'
+ )
assert result is False
@@ -1104,9 +1185,14 @@ class TestGetReviewBranches:
class TestRescanBranches:
"""Tests for rescan_branches()."""
- def _make_tracking_data(self, change_id: str, identifier: str = 'rescan-proj',
- status: str = 'reviewing', revision: int = 1,
- subject: str = 'Test series') -> Dict[str, Any]:
+ def _make_tracking_data(
+ self,
+ change_id: str,
+ identifier: str = 'rescan-proj',
+ status: str = 'reviewing',
+ revision: int = 1,
+ subject: str = 'Test series',
+ ) -> Dict[str, Any]:
return {
'series': {
'identifier': identifier,
@@ -1136,8 +1222,9 @@ class TestRescanBranches:
identifier = 'rescan-single'
review_tracking.init_db(identifier).close()
- tracking_data = self._make_tracking_data('single-change', identifier=identifier,
- status='replied')
+ tracking_data = self._make_tracking_data(
+ 'single-change', identifier=identifier, status='replied'
+ )
branch = _create_review_branch(gitdir, 'single-change', tracking_data)
review_tracking.rescan_branches(identifier, gitdir, branch=branch)
@@ -1145,7 +1232,8 @@ class TestRescanBranches:
conn = review_tracking.get_db(identifier)
cursor = conn.execute(
'SELECT change_id, status, revision FROM series WHERE change_id = ?',
- ('single-change',))
+ ('single-change',),
+ )
row = cursor.fetchone()
assert row is not None
assert row['change_id'] == 'single-change'
@@ -1159,8 +1247,16 @@ class TestRescanBranches:
conn = review_tracking.init_db(identifier)
# Add a series to DB with 'reviewing' status but no corresponding branch
review_tracking.add_series_to_db(
- conn, 'gone-change', 1, 'Gone series', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3)
+ conn,
+ 'gone-change',
+ 1,
+ 'Gone series',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
+ )
review_tracking.update_series_status(conn, 'gone-change', 'reviewing')
conn.close()
@@ -1168,7 +1264,8 @@ class TestRescanBranches:
conn = review_tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?', ('gone-change',))
+ 'SELECT status FROM series WHERE change_id = ?', ('gone-change',)
+ )
row = cursor.fetchone()
assert row['status'] == 'gone'
conn.close()
@@ -1179,15 +1276,17 @@ class TestRescanBranches:
review_tracking.init_db(identifier).close()
# Create branch with a different identifier
- tracking_data = self._make_tracking_data('mismatch-change',
- identifier='other-project')
+ tracking_data = self._make_tracking_data(
+ 'mismatch-change', identifier='other-project'
+ )
_create_review_branch(gitdir, 'mismatch-change', tracking_data)
review_tracking.rescan_branches(identifier, gitdir)
conn = review_tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT * FROM series WHERE change_id = ?', ('mismatch-change',))
+ 'SELECT * FROM series WHERE change_id = ?', ('mismatch-change',)
+ )
row = cursor.fetchone()
assert row is None
conn.close()
@@ -1198,8 +1297,16 @@ class TestRescanBranches:
conn = review_tracking.init_db(identifier)
# Add an 'accepted' series with no branch — should NOT become 'gone'
review_tracking.add_series_to_db(
- conn, 'accepted-change', 1, 'Accepted', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'msgid@example.com', 3)
+ conn,
+ 'accepted-change',
+ 1,
+ 'Accepted',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'msgid@example.com',
+ 3,
+ )
review_tracking.update_series_status(conn, 'accepted-change', 'accepted')
conn.close()
@@ -1207,7 +1314,8 @@ class TestRescanBranches:
conn = review_tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?', ('accepted-change',))
+ 'SELECT status FROM series WHERE change_id = ?', ('accepted-change',)
+ )
row = cursor.fetchone()
assert row['status'] == 'accepted'
conn.close()
@@ -1252,8 +1360,9 @@ class TestRescanBranches:
identifier = 'rescan-sha-change'
review_tracking.init_db(identifier).close()
- tracking_data = self._make_tracking_data('sha-change', identifier=identifier,
- status='reviewing')
+ tracking_data = self._make_tracking_data(
+ 'sha-change', identifier=identifier, status='reviewing'
+ )
branch = _create_review_branch(gitdir, 'sha-change', tracking_data)
# First rescan: registers the branch with status 'reviewing'.
@@ -1262,23 +1371,28 @@ class TestRescanBranches:
# Amend the tracking commit on the branch with a different status.
tracking_data['series']['status'] = 'replied'
- new_msg = ('Cover\n\n' + b4.review.make_review_magic_json(tracking_data))
+ new_msg = 'Cover\n\n' + b4.review.make_review_magic_json(tracking_data)
_ecode, tree = b4.git_run_command(gitdir, ['rev-parse', f'{branch}^{{tree}}'])
tree = tree.strip()
_ecode, parent = b4.git_run_command(gitdir, ['rev-parse', branch])
parent = parent.strip()
_ecode, new_sha = b4.git_run_command(
- gitdir, ['commit-tree', tree, '-p', parent, '-F', '-'],
- stdin=new_msg.encode())
- b4.git_run_command(gitdir, ['update-ref', f'refs/heads/{branch}', new_sha.strip()])
+ gitdir,
+ ['commit-tree', tree, '-p', parent, '-F', '-'],
+ stdin=new_msg.encode(),
+ )
+ b4.git_run_command(
+ gitdir, ['update-ref', f'refs/heads/{branch}', new_sha.strip()]
+ )
# Second rescan: SHA changed, should re-read and update status.
result = review_tracking.rescan_branches(identifier, gitdir)
assert result['changed'] == 1
conn = review_tracking.get_db(identifier)
- row = conn.execute('SELECT status FROM series WHERE change_id = ?',
- ('sha-change',)).fetchone()
+ row = conn.execute(
+ 'SELECT status FROM series WHERE change_id = ?', ('sha-change',)
+ ).fetchone()
assert row['status'] == 'replied'
conn.close()
@@ -1298,7 +1412,9 @@ def _make_test_mbox(n: int, date: str = 'Mon, 15 Jan 2024 10:00:00 +0000') -> by
class TestFollowupCounts:
"""Tests for message_count / seen_message_count tracking."""
- def test_schema_has_followup_columns(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_schema_has_followup_columns(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify fresh DB has message_count, seen_message_count, last_update_check, last_activity_at."""
conn = review_tracking.init_db('fc-schema-test')
cursor = conn.execute('PRAGMA table_info(series)')
@@ -1309,13 +1425,16 @@ class TestFollowupCounts:
assert 'last_activity_at' in col_names
conn.close()
- def test_migration_adds_followup_columns(self, tmp_path: pytest.TempPathFactory) -> None:
+ def test_migration_adds_followup_columns(
+ self, tmp_path: pytest.TempPathFactory
+ ) -> None:
"""Verify v1 DB gets followup/update columns during migration."""
import sqlite3 as _sqlite3
+
db_path = review_tracking.get_db_path('fc-migration-test')
# Manually build a schema-version 1 database (no branch_sha, no followup cols)
raw = _sqlite3.connect(db_path)
- raw.executescript('''
+ raw.executescript("""
CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
CREATE TABLE series (
track_id INTEGER PRIMARY KEY,
@@ -1324,7 +1443,7 @@ class TestFollowupCounts:
status TEXT DEFAULT 'new',
UNIQUE (change_id, revision)
);
- ''')
+ """)
raw.execute('INSERT INTO schema_version (version) VALUES (1)')
raw.commit()
raw.close()
@@ -1345,8 +1464,7 @@ class TestFollowupCounts:
@mock.patch('b4.review.tracking._fetch_thread_mbox_bytes')
def test_first_fetch_initialises_seen(
- self, mock_mbox_bytes: mock.Mock,
- tmp_path: pytest.TempPathFactory
+ self, mock_mbox_bytes: mock.Mock, tmp_path: pytest.TempPathFactory
) -> None:
"""First update_message_counts sets seen = count (no badge shown yet)."""
# 9 unique messages in the thread
@@ -1354,13 +1472,27 @@ class TestFollowupCounts:
conn = review_tracking.init_db('fc-first-test')
review_tracking.add_series_to_db(
- conn, 'fc-change', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'cover@example.com', 3)
+ conn,
+ 'fc-change',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'cover@example.com',
+ 3,
+ )
conn.close()
- series_list = [{'change_id': 'fc-change', 'revision': 1,
- 'message_id': 'cover@example.com', 'num_patches': 3,
- 'status': 'new'}]
+ series_list = [
+ {
+ 'change_id': 'fc-change',
+ 'revision': 1,
+ 'message_id': 'cover@example.com',
+ 'num_patches': 3,
+ 'status': 'new',
+ }
+ ]
result = review_tracking.update_message_counts('fc-first-test', series_list)
assert result['updated'] == 1
assert result['errors'] == 0
@@ -1368,7 +1500,9 @@ class TestFollowupCounts:
conn = review_tracking.get_db('fc-first-test')
row = conn.execute(
'SELECT message_count, seen_message_count, last_update_check, last_activity_at'
- ' FROM series WHERE change_id = ?', ('fc-change',)).fetchone()
+ ' FROM series WHERE change_id = ?',
+ ('fc-change',),
+ ).fetchone()
assert row['message_count'] == 9
# First fetch: seen initialised to same value — no badge yet
assert row['seen_message_count'] == 9
@@ -1379,8 +1513,10 @@ class TestFollowupCounts:
@mock.patch('b4.review.tracking._fetch_new_since')
@mock.patch('b4.review.tracking._fetch_thread_mbox_bytes')
def test_incremental_fetch_adds_new_count(
- self, mock_fetch: mock.Mock,
- mock_new_since: mock.Mock, tmp_path: pytest.TempPathFactory
+ self,
+ mock_fetch: mock.Mock,
+ mock_new_since: mock.Mock,
+ tmp_path: pytest.TempPathFactory,
) -> None:
"""Incremental update adds new message count and keeps seen unchanged."""
# 9 unique messages in the thread
@@ -1390,13 +1526,27 @@ class TestFollowupCounts:
conn = review_tracking.init_db('fc-incr-test')
review_tracking.add_series_to_db(
- conn, 'fc-change2', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'cover2@example.com', 3)
+ conn,
+ 'fc-change2',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'cover2@example.com',
+ 3,
+ )
conn.close()
- series_list = [{'change_id': 'fc-change2', 'revision': 1,
- 'message_id': 'cover2@example.com', 'num_patches': 3,
- 'status': 'reviewing'}]
+ series_list = [
+ {
+ 'change_id': 'fc-change2',
+ 'revision': 1,
+ 'message_id': 'cover2@example.com',
+ 'num_patches': 3,
+ 'status': 'reviewing',
+ }
+ ]
# First fetch: seen = count = 9, last_update_check set
review_tracking.update_message_counts('fc-incr-test', series_list)
@@ -1408,7 +1558,9 @@ class TestFollowupCounts:
conn = review_tracking.get_db('fc-incr-test')
row = conn.execute(
'SELECT message_count, seen_message_count, last_activity_at FROM series'
- ' WHERE change_id = ?', ('fc-change2',)).fetchone()
+ ' WHERE change_id = ?',
+ ('fc-change2',),
+ ).fetchone()
assert row['message_count'] == 12 # 9 + 3
assert row['seen_message_count'] == 9 # badge shows +3
assert row['last_activity_at'] == '2024-02-01T00:00:00+00:00'
@@ -1417,28 +1569,45 @@ class TestFollowupCounts:
@mock.patch('b4.review.tracking._fetch_new_since')
@mock.patch('b4.review.tracking._fetch_thread_mbox_bytes')
def test_incremental_noop_makes_no_db_write(
- self, mock_fetch: mock.Mock,
- mock_new_since: mock.Mock, tmp_path: pytest.TempPathFactory
+ self,
+ mock_fetch: mock.Mock,
+ mock_new_since: mock.Mock,
+ tmp_path: pytest.TempPathFactory,
) -> None:
"""Incremental update with zero new messages writes nothing to the DB."""
# 9 unique messages in the thread
mock_fetch.return_value = _make_test_mbox(9)
- mock_new_since.return_value = (0, None) # no new messages
+ mock_new_since.return_value = (0, None) # no new messages
conn = review_tracking.init_db('fc-noop-test')
review_tracking.add_series_to_db(
- conn, 'fc-change3', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'cover3@example.com', 3)
+ conn,
+ 'fc-change3',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'cover3@example.com',
+ 3,
+ )
conn.close()
- series_list = [{'change_id': 'fc-change3', 'revision': 1,
- 'message_id': 'cover3@example.com', 'num_patches': 3,
- 'status': 'reviewing'}]
+ series_list = [
+ {
+ 'change_id': 'fc-change3',
+ 'revision': 1,
+ 'message_id': 'cover3@example.com',
+ 'num_patches': 3,
+ 'status': 'reviewing',
+ }
+ ]
# First fetch sets the baseline
review_tracking.update_message_counts('fc-noop-test', series_list)
import os
+
db_path = review_tracking.get_db_path('fc-noop-test')
mtime_before = os.path.getmtime(db_path)
@@ -1454,11 +1623,22 @@ class TestFollowupCounts:
"""mark_all_messages_seen sets seen_message_count = message_count."""
conn = review_tracking.init_db('fc-seen-test')
review_tracking.add_series_to_db(
- conn, 'fc-seen', 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'cover3@example.com', 3)
+ conn,
+ 'fc-seen',
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'cover3@example.com',
+ 3,
+ )
# Manually set a delta
- conn.execute('UPDATE series SET message_count = 10, seen_message_count = 6'
- ' WHERE change_id = ?', ('fc-seen',))
+ conn.execute(
+ 'UPDATE series SET message_count = 10, seen_message_count = 6'
+ ' WHERE change_id = ?',
+ ('fc-seen',),
+ )
conn.commit()
review_tracking.mark_all_messages_seen(conn, 'fc-seen', 1)
@@ -1466,8 +1646,10 @@ class TestFollowupCounts:
# Reopen with get_db to get row_factory for named column access
conn = review_tracking.get_db('fc-seen-test')
- row = conn.execute('SELECT message_count, seen_message_count FROM series'
- ' WHERE change_id = ?', ('fc-seen',)).fetchone()
+ row = conn.execute(
+ 'SELECT message_count, seen_message_count FROM series WHERE change_id = ?',
+ ('fc-seen',),
+ ).fetchone()
assert row['message_count'] == 10
assert row['seen_message_count'] == 10
conn.close()
@@ -1488,14 +1670,27 @@ class TestFollowupCounts:
for status in ('archived', 'accepted', 'thanked'):
cid = f'fc-{status}'
review_tracking.add_series_to_db(
- conn, cid, 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', f'{cid}@example.com', 3)
+ conn,
+ cid,
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ f'{cid}@example.com',
+ 3,
+ )
review_tracking.update_series_status(conn, cid, status)
conn.close()
series_list = [
- {'change_id': f'fc-{s}', 'revision': 1,
- 'message_id': f'fc-{s}@example.com', 'num_patches': 3, 'status': s}
+ {
+ 'change_id': f'fc-{s}',
+ 'revision': 1,
+ 'message_id': f'fc-{s}@example.com',
+ 'num_patches': 3,
+ 'status': s,
+ }
for s in ('archived', 'accepted', 'thanked')
]
result = review_tracking.update_message_counts('fc-skip-test', series_list)
@@ -1532,7 +1727,9 @@ def _make_test_msg(msgid: str = 'test@example.com') -> EmailMessage:
return msg
-def _make_blob_tracking_data(change_id: str, identifier: str = 'blob-proj') -> Dict[str, Any]:
+def _make_blob_tracking_data(
+ change_id: str, identifier: str = 'blob-proj'
+) -> Dict[str, Any]:
"""Return a minimal tracking dict for blob tests."""
return {
'series': {
@@ -1567,8 +1764,7 @@ class TestFollowupBlob:
) -> None:
"""_store_thread_blob serializes msgs via save_mboxrd_mbox and records SHA."""
change_id = 'blob-write-test'
- _create_review_branch(gitdir, change_id,
- _make_blob_tracking_data(change_id))
+ _create_review_branch(gitdir, change_id, _make_blob_tracking_data(change_id))
msgs = [_make_test_msg('cover@example.com')]
blob_sha = review_tracking._store_thread_blob(gitdir, change_id, msgs)
@@ -1578,13 +1774,13 @@ class TestFollowupBlob:
expected_buf = io.BytesIO()
b4.save_mboxrd_mbox(msgs, expected_buf)
ecode, content = b4.git_run_command(
- gitdir, ['cat-file', 'blob', blob_sha], decode=False)
+ gitdir, ['cat-file', 'blob', blob_sha], decode=False
+ )
assert ecode == 0
assert content == expected_buf.getvalue()
# Tracking commit JSON must carry the blob SHA
- _cover, loaded = b4.review.load_tracking(
- gitdir, f'b4/review/{change_id}')
+ _cover, loaded = b4.review.load_tracking(gitdir, f'b4/review/{change_id}')
assert loaded['series']['thread-blob'] == blob_sha
def test_store_thread_blob_skips_update_when_sha_unchanged(
@@ -1592,8 +1788,7 @@ class TestFollowupBlob:
) -> None:
"""_store_thread_blob avoids a new tracking commit when SHA is unchanged."""
change_id = 'blob-nochurn-test'
- _create_review_branch(gitdir, change_id,
- _make_blob_tracking_data(change_id))
+ _create_review_branch(gitdir, change_id, _make_blob_tracking_data(change_id))
msgs = [_make_test_msg('nochurn@example.com')]
@@ -1601,7 +1796,8 @@ class TestFollowupBlob:
assert sha1 is not None
ecode, tip1 = b4.git_run_command(
- gitdir, ['rev-parse', f'b4/review/{change_id}'])
+ gitdir, ['rev-parse', f'b4/review/{change_id}']
+ )
assert ecode == 0
# Second call with identical messages — SHA and branch tip unchanged
@@ -1609,7 +1805,8 @@ class TestFollowupBlob:
assert sha2 == sha1
ecode, tip2 = b4.git_run_command(
- gitdir, ['rev-parse', f'b4/review/{change_id}'])
+ gitdir, ['rev-parse', f'b4/review/{change_id}']
+ )
assert ecode == 0
assert tip2.strip() == tip1.strip()
@@ -1617,56 +1814,70 @@ class TestFollowupBlob:
"""get_thread_mbox returns the exact bytes written to the blob."""
sample = b'From mboxrd@z Thu Jan 1 00:00:00 1970\nSubject: hi\n\nbody\n'
ecode, blob_sha = b4.git_run_command(
- gitdir, ['hash-object', '-w', '--stdin'], stdin=sample)
+ gitdir, ['hash-object', '-w', '--stdin'], stdin=sample
+ )
assert ecode == 0
result = review_tracking.get_thread_mbox(gitdir, blob_sha.strip())
assert result == sample
- def test_get_thread_mbox_returns_none_for_missing_sha(
- self, gitdir: str
- ) -> None:
+ def test_get_thread_mbox_returns_none_for_missing_sha(self, gitdir: str) -> None:
"""get_thread_mbox returns None (not an exception) for a bogus SHA."""
result = review_tracking.get_thread_mbox(gitdir, 'deadbeef' * 5)
assert result is None
@mock.patch('b4.review.tracking._fetch_thread_mbox_bytes')
def test_update_message_counts_stores_blob_on_first_fetch(
- self, mock_mbox: mock.Mock,
- gitdir: str
+ self, mock_mbox: mock.Mock, gitdir: str
) -> None:
"""update_message_counts writes a thread blob on the first fetch."""
mock_mbox.return_value = _make_mbox_bytes(9, prefix='ff')
change_id = 'blob-first-fetch'
- _create_review_branch(gitdir, change_id,
- _make_blob_tracking_data(change_id, 'blob-ff-proj'))
+ _create_review_branch(
+ gitdir, change_id, _make_blob_tracking_data(change_id, 'blob-ff-proj')
+ )
conn = review_tracking.init_db('blob-ff-proj')
review_tracking.add_series_to_db(
- conn, change_id, 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'blob-first@example.com', 3)
+ conn,
+ change_id,
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'blob-first@example.com',
+ 3,
+ )
conn.close()
- series_list = [{'change_id': change_id, 'revision': 1,
- 'message_id': 'blob-first@example.com',
- 'num_patches': 3, 'status': 'reviewing'}]
+ series_list = [
+ {
+ 'change_id': change_id,
+ 'revision': 1,
+ 'message_id': 'blob-first@example.com',
+ 'num_patches': 3,
+ 'status': 'reviewing',
+ }
+ ]
review_tracking.update_message_counts(
- 'blob-ff-proj', series_list, topdir=gitdir)
+ 'blob-ff-proj', series_list, topdir=gitdir
+ )
_cover, loaded = b4.review.load_tracking(gitdir, f'b4/review/{change_id}')
blob_sha = loaded['series'].get('thread-blob')
assert blob_sha is not None
# Blob must be readable
ecode, _ = b4.git_run_command(
- gitdir, ['cat-file', 'blob', blob_sha], decode=False)
+ gitdir, ['cat-file', 'blob', blob_sha], decode=False
+ )
assert ecode == 0
@mock.patch('b4.review.tracking._fetch_new_since')
@mock.patch('b4.review.tracking._fetch_thread_mbox_bytes')
def test_update_message_counts_updates_blob_on_incremental(
- self, mock_fetch: mock.Mock,
- mock_new_since: mock.Mock, gitdir: str
+ self, mock_fetch: mock.Mock, mock_new_since: mock.Mock, gitdir: str
) -> None:
"""update_message_counts replaces the blob when new replies arrive."""
# Different prefixes → different Message-IDs → different blobs
@@ -1676,22 +1887,38 @@ class TestFollowupBlob:
mock_new_since.return_value = (3, '2024-02-01T00:00:00+00:00')
change_id = 'blob-incr-test'
- _create_review_branch(gitdir, change_id,
- _make_blob_tracking_data(change_id, 'blob-incr-proj'))
+ _create_review_branch(
+ gitdir, change_id, _make_blob_tracking_data(change_id, 'blob-incr-proj')
+ )
conn = review_tracking.init_db('blob-incr-proj')
review_tracking.add_series_to_db(
- conn, change_id, 1, 'Subject', 'Author', 'a@example.com',
- '2024-01-15T10:00:00+00:00', 'blob-incr@example.com', 3)
+ conn,
+ change_id,
+ 1,
+ 'Subject',
+ 'Author',
+ 'a@example.com',
+ '2024-01-15T10:00:00+00:00',
+ 'blob-incr@example.com',
+ 3,
+ )
conn.close()
- series_list = [{'change_id': change_id, 'revision': 1,
- 'message_id': 'blob-incr@example.com',
- 'num_patches': 3, 'status': 'reviewing'}]
+ series_list = [
+ {
+ 'change_id': change_id,
+ 'revision': 1,
+ 'message_id': 'blob-incr@example.com',
+ 'num_patches': 3,
+ 'status': 'reviewing',
+ }
+ ]
# First fetch — stores initial blob
review_tracking.update_message_counts(
- 'blob-incr-proj', series_list, topdir=gitdir)
+ 'blob-incr-proj', series_list, topdir=gitdir
+ )
_cover, loaded = b4.review.load_tracking(gitdir, f'b4/review/{change_id}')
sha_initial = loaded['series'].get('thread-blob')
assert sha_initial is not None
@@ -1699,7 +1926,8 @@ class TestFollowupBlob:
# Incremental — _fetch_thread_mbox_bytes now returns the larger mbox
mock_fetch.return_value = larger_mbox
review_tracking.update_message_counts(
- 'blob-incr-proj', series_list, topdir=gitdir)
+ 'blob-incr-proj', series_list, topdir=gitdir
+ )
_cover, loaded = b4.review.load_tracking(gitdir, f'b4/review/{change_id}')
sha_updated = loaded['series'].get('thread-blob')
@@ -1710,13 +1938,20 @@ class TestFollowupBlob:
class TestPatchState:
"""Tests for _get_patch_state() and _set_patch_state()."""
- _USERCFG: Dict[str, Union[str, List[str], None]] = {'email': 'reviewer@example.com', 'name': 'Test Reviewer'}
+ _USERCFG: Dict[str, Union[str, List[str], None]] = {
+ 'email': 'reviewer@example.com',
+ 'name': 'Test Reviewer',
+ }
def _make_target(self, review_data: Dict[str, Any] | None = None) -> Dict[str, Any]:
"""Return a minimal target dict, optionally with review data."""
if review_data is None:
return {}
- return {'reviews': {self._USERCFG['email']: {'name': 'Test Reviewer', **review_data}}}
+ return {
+ 'reviews': {
+ self._USERCFG['email']: {'name': 'Test Reviewer', **review_data}
+ }
+ }
def test_no_data(self) -> None:
"""Empty reviews dict → no state."""
@@ -1730,7 +1965,9 @@ class TestPatchState:
def test_comments(self) -> None:
"""Inline comments list → 'draft'."""
- target = self._make_target({'comments': [{'path': 'a.c', 'line': 1, 'text': 'hi'}]})
+ target = self._make_target(
+ {'comments': [{'path': 'a.c', 'line': 1, 'text': 'hi'}]}
+ )
assert b4.review._get_patch_state(target, self._USERCFG) == 'draft'
def test_reply(self) -> None:
@@ -1740,25 +1977,35 @@ class TestPatchState:
def test_reviewed_by(self) -> None:
"""Reviewed-by trailer → 'done'."""
- target = self._make_target({'trailers': ['Reviewed-by: Test Reviewer <reviewer@example.com>']})
+ target = self._make_target(
+ {'trailers': ['Reviewed-by: Test Reviewer <reviewer@example.com>']}
+ )
assert b4.review._get_patch_state(target, self._USERCFG) == 'done'
def test_acked_by(self) -> None:
"""Acked-by trailer → 'done'."""
- target = self._make_target({'trailers': ['Acked-by: Test Reviewer <reviewer@example.com>']})
+ target = self._make_target(
+ {'trailers': ['Acked-by: Test Reviewer <reviewer@example.com>']}
+ )
assert b4.review._get_patch_state(target, self._USERCFG) == 'done'
def test_nacked_by_alone(self) -> None:
"""NACKed-by trailer alone → 'draft' (explanation required)."""
- target = self._make_target({'trailers': ['NACKed-by: Test Reviewer <reviewer@example.com>']})
+ target = self._make_target(
+ {'trailers': ['NACKed-by: Test Reviewer <reviewer@example.com>']}
+ )
assert b4.review._get_patch_state(target, self._USERCFG) == 'draft'
def test_nacked_by_with_acked(self) -> None:
"""NACK wins over Acked-by — result is still 'draft'."""
- target = self._make_target({'trailers': [
- 'NACKed-by: Test Reviewer <reviewer@example.com>',
- 'Acked-by: Test Reviewer <reviewer@example.com>',
- ]})
+ target = self._make_target(
+ {
+ 'trailers': [
+ 'NACKed-by: Test Reviewer <reviewer@example.com>',
+ 'Acked-by: Test Reviewer <reviewer@example.com>',
+ ]
+ }
+ )
assert b4.review._get_patch_state(target, self._USERCFG) == 'draft'
def test_explicit_done(self) -> None:
@@ -1773,10 +2020,12 @@ class TestPatchState:
def test_explicit_done_beats_nack(self) -> None:
"""Explicit done overrides a NACKed-by trailer (human override wins)."""
- target = self._make_target({
- 'patch-state': 'done',
- 'trailers': ['NACKed-by: Test Reviewer <reviewer@example.com>'],
- })
+ target = self._make_target(
+ {
+ 'patch-state': 'done',
+ 'trailers': ['NACKed-by: Test Reviewer <reviewer@example.com>'],
+ }
+ )
assert b4.review._get_patch_state(target, self._USERCFG) == 'done'
def test_set_and_clear(self) -> None:
@@ -1807,8 +2056,7 @@ class TestBuildReplyFromComments:
'diff --git a/foo.py b/foo.py\n'
'--- a/foo.py\n'
'+++ b/foo.py\n'
- '@@ -0,0 +1,40 @@\n'
- + ''.join(f'+line{i}\n' for i in range(1, 41))
+ '@@ -0,0 +1,40 @@\n' + ''.join(f'+line{i}\n' for i in range(1, 41))
)
def _make_comment(self, line: int, text: str) -> dict[str, Any]:
@@ -1929,22 +2177,25 @@ class TestFormatSnoozeUntil:
def test_expired_datetime(self) -> None:
"""A datetime in the past returns 'expired'."""
- past = (datetime.datetime.now(datetime.timezone.utc)
- - datetime.timedelta(hours=1)).isoformat()
+ past = (
+ datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
+ ).isoformat()
assert _format_snooze_until(past) == 'expired'
def test_future_days_hours_minutes(self) -> None:
"""A datetime ~1d 2h 30m in the future shows all three components."""
- target = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(days=1, hours=2, minutes=30, seconds=30))
+ target = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
+ days=1, hours=2, minutes=30, seconds=30
+ )
result = _format_snooze_until(target.isoformat())
assert result.startswith('wakes in 1d 2h 30m')
assert '(' in result # contains the local date/time
def test_future_hours_only(self) -> None:
"""A datetime exactly 3h in the future shows hours (and maybe minutes)."""
- target = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(hours=3, seconds=30))
+ target = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
+ hours=3, seconds=30
+ )
result = _format_snooze_until(target.isoformat())
assert 'wakes in' in result
assert '3h' in result
@@ -1952,8 +2203,9 @@ class TestFormatSnoozeUntil:
def test_future_minutes_only(self) -> None:
"""A datetime 45m in the future shows only minutes."""
- target = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(minutes=45, seconds=30))
+ target = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
+ minutes=45, seconds=30
+ )
result = _format_snooze_until(target.isoformat())
assert 'wakes in 45m' in result
assert 'd' not in result.split('(')[0]
@@ -1961,8 +2213,9 @@ class TestFormatSnoozeUntil:
def test_future_less_than_one_minute(self) -> None:
"""A datetime <1m away shows '<1m'."""
- target = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(seconds=20))
+ target = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
+ seconds=20
+ )
result = _format_snooze_until(target.isoformat())
assert 'wakes in <1m' in result
@@ -1976,8 +2229,9 @@ class TestFormatSnoozeUntil:
def test_local_time_shown(self) -> None:
"""The parenthesised local time uses YYYY-MM-DD HH:MM format."""
- target = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(hours=6))
+ target = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(
+ hours=6
+ )
result = _format_snooze_until(target.isoformat())
local_dt = target.astimezone()
expected_str = local_dt.strftime('%Y-%m-%d %H:%M')
@@ -1987,29 +2241,43 @@ class TestFormatSnoozeUntil:
class TestSnoozeDurationRegex:
"""Tests for SnoozeScreen._DURATION_RE pattern matching."""
- @pytest.mark.parametrize('input_str,expected_value,expected_unit', [
- ('30m', 30, 'm'),
- ('3h', 3, 'h'),
- ('1d', 1, 'd'),
- ('2w', 2, 'w'),
- ('7', 7, ''),
- ('30 m', 30, 'm'),
- ('3H', 3, 'H'),
- ('1D', 1, 'D'),
- ('2W', 2, 'W'),
- ('45M', 45, 'M'),
- ])
- def test_valid_durations(self, input_str: str,
- expected_value: int, expected_unit: str) -> None:
+ @pytest.mark.parametrize(
+ 'input_str,expected_value,expected_unit',
+ [
+ ('30m', 30, 'm'),
+ ('3h', 3, 'h'),
+ ('1d', 1, 'd'),
+ ('2w', 2, 'w'),
+ ('7', 7, ''),
+ ('30 m', 30, 'm'),
+ ('3H', 3, 'H'),
+ ('1D', 1, 'D'),
+ ('2W', 2, 'W'),
+ ('45M', 45, 'M'),
+ ],
+ )
+ def test_valid_durations(
+ self, input_str: str, expected_value: int, expected_unit: str
+ ) -> None:
"""Valid duration strings are parsed correctly."""
m = SnoozeScreen._DURATION_RE.match(input_str)
assert m is not None
assert int(m.group(1)) == expected_value
assert m.group(2) == expected_unit
- @pytest.mark.parametrize('input_str', [
- 'abc', '3x', 'h3', 'm', '', '3.5h', '-1d', '3hh',
- ])
+ @pytest.mark.parametrize(
+ 'input_str',
+ [
+ 'abc',
+ '3x',
+ 'h3',
+ 'm',
+ '',
+ '3.5h',
+ '-1d',
+ '3hh',
+ ],
+ )
def test_invalid_durations(self, input_str: str) -> None:
"""Invalid duration strings are rejected."""
assert SnoozeScreen._DURATION_RE.match(input_str) is None
@@ -2018,8 +2286,9 @@ class TestSnoozeDurationRegex:
class TestGetExpiredSnoozedDatetime:
"""Verify get_expired_snoozed() works with full ISO datetimes."""
- def _make_snoozed_series(self, conn: Any, change_id: str,
- snoozed_until: str) -> None:
+ def _make_snoozed_series(
+ self, conn: Any, change_id: str, snoozed_until: str
+ ) -> None:
"""Insert a snoozed series with a given wake-up time."""
review_tracking.add_series_to_db(
conn,
@@ -2037,8 +2306,9 @@ class TestGetExpiredSnoozedDatetime:
def test_past_datetime_is_expired(self) -> None:
"""A series snoozed until a past datetime shows up as expired."""
conn = review_tracking.init_db('snooze-past-dt')
- past = (datetime.datetime.now(datetime.timezone.utc)
- - datetime.timedelta(minutes=5)).isoformat()
+ past = (
+ datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(minutes=5)
+ ).isoformat()
self._make_snoozed_series(conn, 'past-dt-id', past)
expired = review_tracking.get_expired_snoozed(conn)
assert len(expired) == 1
@@ -2048,8 +2318,9 @@ class TestGetExpiredSnoozedDatetime:
def test_future_datetime_not_expired(self) -> None:
"""A series snoozed until a future datetime does not show up."""
conn = review_tracking.init_db('snooze-future-dt')
- future = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(hours=2)).isoformat()
+ future = (
+ datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=2)
+ ).isoformat()
self._make_snoozed_series(conn, 'future-dt-id', future)
expired = review_tracking.get_expired_snoozed(conn)
assert len(expired) == 0
@@ -2058,8 +2329,10 @@ class TestGetExpiredSnoozedDatetime:
def test_past_date_only_is_expired(self) -> None:
"""A legacy date-only value in the past still works."""
conn = review_tracking.init_db('snooze-past-date')
- yesterday = (datetime.datetime.now(datetime.timezone.utc).date()
- - datetime.timedelta(days=1)).isoformat()
+ yesterday = (
+ datetime.datetime.now(datetime.timezone.utc).date()
+ - datetime.timedelta(days=1)
+ ).isoformat()
self._make_snoozed_series(conn, 'past-date-id', yesterday)
expired = review_tracking.get_expired_snoozed(conn)
assert len(expired) == 1
@@ -2069,8 +2342,10 @@ class TestGetExpiredSnoozedDatetime:
def test_future_date_only_not_expired(self) -> None:
"""A legacy date-only value in the future still works."""
conn = review_tracking.init_db('snooze-future-date')
- tomorrow = (datetime.datetime.now(datetime.timezone.utc).date()
- + datetime.timedelta(days=2)).isoformat()
+ tomorrow = (
+ datetime.datetime.now(datetime.timezone.utc).date()
+ + datetime.timedelta(days=2)
+ ).isoformat()
self._make_snoozed_series(conn, 'future-date-id', tomorrow)
expired = review_tracking.get_expired_snoozed(conn)
assert len(expired) == 0
@@ -2079,12 +2354,17 @@ class TestGetExpiredSnoozedDatetime:
def test_mixed_date_and_datetime(self) -> None:
"""Both legacy date-only and new datetime values handled together."""
conn = review_tracking.init_db('snooze-mixed')
- past_dt = (datetime.datetime.now(datetime.timezone.utc)
- - datetime.timedelta(minutes=30)).isoformat()
- yesterday = (datetime.datetime.now(datetime.timezone.utc).date()
- - datetime.timedelta(days=1)).isoformat()
- future_dt = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(hours=5)).isoformat()
+ past_dt = (
+ datetime.datetime.now(datetime.timezone.utc)
+ - datetime.timedelta(minutes=30)
+ ).isoformat()
+ yesterday = (
+ datetime.datetime.now(datetime.timezone.utc).date()
+ - datetime.timedelta(days=1)
+ ).isoformat()
+ future_dt = (
+ datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=5)
+ ).isoformat()
self._make_snoozed_series(conn, 'expired-dt', past_dt)
self._make_snoozed_series(conn, 'expired-date', yesterday)
self._make_snoozed_series(conn, 'still-sleeping', future_dt)
@@ -2107,8 +2387,9 @@ class TestGetExpiredSnoozedDatetime:
# Add a tag-based snooze
self._make_snoozed_series(conn, 'tag-id', 'tag:v6.15-rc3')
# Add a time-based snooze
- future = (datetime.datetime.now(datetime.timezone.utc)
- + datetime.timedelta(hours=2)).isoformat()
+ future = (
+ datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=2)
+ ).isoformat()
self._make_snoozed_series(conn, 'time-id', future)
tag_results = review_tracking.get_tag_snoozed(conn)
assert len(tag_results) == 1
@@ -2119,11 +2400,13 @@ class TestGetExpiredSnoozedDatetime:
# -- Tests for attestation DB operations -------------------------------------
+
class TestAttestationDb:
"""Tests for attestation storage and schema migration."""
- def _add_test_series(self, conn: Any, change_id: str = 'att-test-id',
- revision: int = 1) -> int:
+ def _add_test_series(
+ self, conn: Any, change_id: str = 'att-test-id', revision: int = 1
+ ) -> int:
"""Insert a minimal series row and return its track_id."""
return review_tracking.add_series_to_db(
conn,
@@ -2153,8 +2436,9 @@ class TestAttestationDb:
conn = review_tracking.init_db(ident)
self._add_test_series(conn)
conn.close()
- review_tracking.update_attestation(ident, 'att-test-id', 1,
- 'signed:DKIM/kernel.org')
+ review_tracking.update_attestation(
+ ident, 'att-test-id', 1, 'signed:DKIM/kernel.org'
+ )
conn = review_tracking.get_db(ident)
row = conn.execute(
"SELECT attestation FROM series WHERE change_id = 'att-test-id'"
@@ -2183,8 +2467,9 @@ class TestAttestationDb:
self._add_test_series(conn)
conn.close()
review_tracking.update_attestation(ident, 'att-test-id', 1, 'none')
- review_tracking.update_attestation(ident, 'att-test-id', 1,
- 'signed:DKIM/kernel.org')
+ review_tracking.update_attestation(
+ ident, 'att-test-id', 1, 'signed:DKIM/kernel.org'
+ )
conn = review_tracking.get_db(ident)
row = conn.execute(
"SELECT attestation FROM series WHERE change_id = 'att-test-id'"
@@ -2199,8 +2484,9 @@ class TestAttestationDb:
self._add_test_series(conn)
conn.close()
# revision 99 doesn't exist — should not raise
- review_tracking.update_attestation(ident, 'att-test-id', 99,
- 'signed:DKIM/kernel.org')
+ review_tracking.update_attestation(
+ ident, 'att-test-id', 99, 'signed:DKIM/kernel.org'
+ )
conn = review_tracking.get_db(ident)
row = conn.execute(
"SELECT attestation FROM series WHERE change_id = 'att-test-id'"
@@ -2214,8 +2500,9 @@ class TestAttestationDb:
conn = review_tracking.init_db(ident)
self._add_test_series(conn)
conn.close()
- review_tracking.update_attestation(ident, 'att-test-id', 1,
- 'nokey:ed25519/dev@example.com')
+ review_tracking.update_attestation(
+ ident, 'att-test-id', 1, 'nokey:ed25519/dev@example.com'
+ )
series_list = review_tracking.get_all_tracked_series(ident)
assert len(series_list) == 1
assert series_list[0]['attestation'] == 'nokey:ed25519/dev@example.com'
@@ -2225,8 +2512,9 @@ class TestAttestationDb:
ident = 'snoozed-listing'
conn = review_tracking.init_db(ident)
self._add_test_series(conn)
- review_tracking.snooze_series(conn, 'att-test-id', '2026-06-01T00:00:00',
- revision=1)
+ review_tracking.snooze_series(
+ conn, 'att-test-id', '2026-06-01T00:00:00', revision=1
+ )
conn.close()
series_list = review_tracking.get_all_tracked_series(ident)
assert len(series_list) == 1
@@ -2235,13 +2523,14 @@ class TestAttestationDb:
def test_schema_v4_migration_adds_attestation(self) -> None:
"""Migrating from schema v4 adds the attestation column."""
import sqlite3
+
ident = 'att-migrate-v4'
# Create a v4-style database manually
db_path = review_tracking.get_db_path(ident)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
conn = sqlite3.connect(db_path)
# Create the tables without the attestation column
- conn.executescript('''
+ conn.executescript("""
CREATE TABLE schema_version (version INTEGER PRIMARY KEY);
INSERT INTO schema_version VALUES (4);
CREATE TABLE series (
@@ -2267,7 +2556,7 @@ class TestAttestationDb:
UNIQUE (change_id, revision)
);
INSERT INTO series (change_id, revision, subject) VALUES ('migrate-id', 1, 'Test');
- ''')
+ """)
conn.close()
# Opening via get_db triggers migration
conn = review_tracking.get_db(ident)
@@ -2282,6 +2571,7 @@ class TestAttestationDb:
# -- Tests for _format_attestation() display helper --------------------------
+
class TestFormatAttestation:
"""Tests for the _format_attestation() display helper."""
@@ -2325,7 +2615,9 @@ class TestFormatAttestation:
def test_multiple_attestors_comma_separated(self) -> None:
"""Multiple attestors are comma-separated in the output."""
- text = _format_attestation('signed:DKIM/kernel.org;nokey:ed25519/dev@example.com')
+ text = _format_attestation(
+ 'signed:DKIM/kernel.org;nokey:ed25519/dev@example.com'
+ )
assert text is not None
plain = text.plain
assert ', ' in plain
diff --git a/src/tests/test_three_way_merge.py b/src/tests/test_three_way_merge.py
index c0127bf..2b4391e 100644
--- a/src/tests/test_three_way_merge.py
+++ b/src/tests/test_three_way_merge.py
@@ -44,8 +44,9 @@ class TestRewriteFetchHeadOrigin:
with open(fh_path, 'w') as fh:
fh.write("abc123\t\tnot-for-merge\tbranch 'master' of /tmp/b4-worktree\n")
- b4._rewrite_fetch_head_origin(gitdir, '/tmp/b4-worktree',
- 'https://lore.kernel.org/r/test@msg')
+ b4._rewrite_fetch_head_origin(
+ gitdir, '/tmp/b4-worktree', 'https://lore.kernel.org/r/test@msg'
+ )
with open(fh_path, 'r') as fh:
contents = fh.read()
@@ -58,8 +59,7 @@ class TestRewriteFetchHeadOrigin:
with open(fh_path, 'w') as fh:
fh.write(original)
- b4._rewrite_fetch_head_origin(gitdir, '/tmp/nonexistent',
- 'https://example.com')
+ b4._rewrite_fetch_head_origin(gitdir, '/tmp/nonexistent', 'https://example.com')
with open(fh_path, 'r') as fh:
contents = fh.read()
@@ -68,8 +68,10 @@ class TestRewriteFetchHeadOrigin:
def test_rewrites_multiple_occurrences(self, gitdir: str) -> None:
fh_path = os.path.join(gitdir, '.git', 'FETCH_HEAD')
with open(fh_path, 'w') as fh:
- fh.write("aaa\t\tnot-for-merge\tbranch 'master' of /tmp/wt\n"
- "bbb\t\tnot-for-merge\tbranch 'master' of /tmp/wt\n")
+ fh.write(
+ "aaa\t\tnot-for-merge\tbranch 'master' of /tmp/wt\n"
+ "bbb\t\tnot-for-merge\tbranch 'master' of /tmp/wt\n"
+ )
b4._rewrite_fetch_head_origin(gitdir, '/tmp/wt', 'https://example.com')
@@ -124,8 +126,7 @@ def _build_conflicting_patches(gitdir: str) -> Tuple[bytes, str]:
# Create patch on a temp branch (from original HEAD)
b4.git_run_command(gitdir, ['checkout', '-b', 'conflict-patch'])
with open(os.path.join(gitdir, 'file1.txt'), 'w') as fh:
- fh.write('PATCH version of file 1.\n'
- 'Rewritten entirely by the patch.\n')
+ fh.write('PATCH version of file 1.\nRewritten entirely by the patch.\n')
b4.git_run_command(gitdir, ['add', 'file1.txt'])
b4.git_run_command(gitdir, ['commit', '-m', 'Rewrite file1 (patch side)'])
@@ -136,8 +137,7 @@ def _build_conflicting_patches(gitdir: str) -> Tuple[bytes, str]:
b4.git_run_command(gitdir, ['checkout', 'master'])
b4.git_run_command(gitdir, ['branch', '-D', 'conflict-patch'])
with open(os.path.join(gitdir, 'file1.txt'), 'w') as fh:
- fh.write('MASTER version of file 1.\n'
- 'Also rewritten, but differently.\n')
+ fh.write('MASTER version of file 1.\nAlso rewritten, but differently.\n')
b4.git_run_command(gitdir, ['add', 'file1.txt'])
b4.git_run_command(gitdir, ['commit', '-m', 'Rewrite file1 (master side)'])
@@ -154,8 +154,7 @@ class TestGitFetchAmIntoRepo:
assert common_dir is not None
gwt = os.path.join(common_dir, 'b4-shazam-worktree')
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base=base,
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base=base, am_flags=['-3'])
# Worktree should be cleaned up after success
assert not os.path.exists(gwt)
@@ -169,8 +168,9 @@ class TestGitFetchAmIntoRepo:
ambytes, base = _build_clean_patches(gitdir)
origin = 'https://lore.kernel.org/r/test@example.com'
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base=base,
- origin=origin, am_flags=['-3'])
+ b4.git_fetch_am_into_repo(
+ gitdir, ambytes, at_base=base, origin=origin, am_flags=['-3']
+ )
fh_path = os.path.join(gitdir, '.git', 'FETCH_HEAD')
with open(fh_path, 'r') as fh:
@@ -183,8 +183,9 @@ class TestGitFetchAmIntoRepo:
try:
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(
+ gitdir, ambytes, at_base='HEAD', am_flags=['-3']
+ )
assert exc_info.value.worktree_path != ''
assert exc_info.value.output != ''
@@ -202,16 +203,17 @@ class TestGitFetchAmIntoRepo:
try:
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(
+ gitdir, ambytes, at_base='HEAD', am_flags=['-3']
+ )
wt_path = exc_info.value.worktree_path
# Worktree must still exist for user to resolve
assert os.path.isdir(wt_path)
# rebase-apply should be present (am still in progress)
ecode, wt_gitdir = b4.git_run_command(
- wt_path, ['rev-parse', '--git-dir'],
- logstderr=True, rundir=wt_path)
+ wt_path, ['rev-parse', '--git-dir'], logstderr=True, rundir=wt_path
+ )
assert ecode == 0
rebase_apply = os.path.join(wt_gitdir.strip(), 'rebase-apply')
assert os.path.isdir(rebase_apply)
@@ -227,8 +229,7 @@ class TestGitFetchAmIntoRepo:
"""Patches also apply cleanly without -3 (baseline)."""
ambytes, base = _build_clean_patches(gitdir)
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base=base,
- am_flags=[])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base=base, am_flags=[])
fh_path = os.path.join(gitdir, '.git', 'FETCH_HEAD')
assert os.path.exists(fh_path)
@@ -242,8 +243,9 @@ class TestGitFetchAmIntoRepo:
if os.path.exists(fh_path):
os.unlink(fh_path)
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base=base,
- check_only=True, am_flags=['-3'])
+ b4.git_fetch_am_into_repo(
+ gitdir, ambytes, at_base=base, check_only=True, am_flags=['-3']
+ )
# check_only should not fetch (no FETCH_HEAD created)
assert not os.path.exists(fh_path)
@@ -259,9 +261,11 @@ class TestSuspendToShellCwd:
"""Test that _suspend_to_shell passes cwd to subprocess.run."""
@patch('b4.tui._common.subprocess.run')
- def test_cwd_passed_through(self, mock_run: Any,
- monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_cwd_passed_through(
+ self, mock_run: Any, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
from b4.review_tui._common import _suspend_to_shell
+
# Use a shell name that is neither bash nor zsh so we hit
# the simple else branch (no tempfile/rcfile logic).
monkeypatch.setenv('SHELL', '/tmp/fakeshell')
@@ -273,9 +277,11 @@ class TestSuspendToShellCwd:
assert kwargs.get('cwd') == '/tmp/test-worktree'
@patch('b4.tui._common.subprocess.run')
- def test_cwd_none_by_default(self, mock_run: Any,
- monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_cwd_none_by_default(
+ self, mock_run: Any, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
from b4.review_tui._common import _suspend_to_shell
+
monkeypatch.setenv('SHELL', '/tmp/fakeshell')
_suspend_to_shell()
@@ -285,9 +291,11 @@ class TestSuspendToShellCwd:
assert kwargs.get('cwd') is None
@patch('b4.tui._common.subprocess.run')
- def test_hint_appears_in_env(self, mock_run: Any,
- monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_hint_appears_in_env(
+ self, mock_run: Any, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
from b4.review_tui._common import _suspend_to_shell
+
monkeypatch.setenv('SHELL', '/tmp/fakeshell')
_suspend_to_shell(hint='b4 conflict', cwd='/tmp/wt')
@@ -310,30 +318,29 @@ class TestConflictResolutionFlow:
ambytes, _base = _build_conflicting_patches(gitdir)
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
wt = exc_info.value.worktree_path
# --- same steps the TUI handler takes ---
# 1. Disable sparse checkout so files are visible
- b4.git_run_command(wt, ['sparse-checkout', 'disable'],
- logstderr=True, rundir=wt)
+ b4.git_run_command(
+ wt, ['sparse-checkout', 'disable'], logstderr=True, rundir=wt
+ )
assert os.path.exists(os.path.join(wt, 'file1.txt'))
# 2. Simulate user resolving: accept theirs and continue
- b4.git_run_command(wt, ['checkout', '--theirs', '.'],
- logstderr=True, rundir=wt)
- b4.git_run_command(wt, ['add', '-A'],
- logstderr=True, rundir=wt)
- ecode, _out = b4.git_run_command(wt, ['am', '--continue'],
- logstderr=True, rundir=wt)
+ b4.git_run_command(wt, ['checkout', '--theirs', '.'], logstderr=True, rundir=wt)
+ b4.git_run_command(wt, ['add', '-A'], logstderr=True, rundir=wt)
+ ecode, _out = b4.git_run_command(
+ wt, ['am', '--continue'], logstderr=True, rundir=wt
+ )
assert ecode == 0
# 3. Verify rebase-apply is gone (am completed)
ecode, wt_gitdir = b4.git_run_command(
- wt, ['rev-parse', '--git-dir'],
- logstderr=True, rundir=wt)
+ wt, ['rev-parse', '--git-dir'], logstderr=True, rundir=wt
+ )
assert ecode == 0
rebase_apply = os.path.join(wt_gitdir.strip(), 'rebase-apply')
assert not os.path.isdir(rebase_apply)
@@ -353,15 +360,14 @@ class TestConflictResolutionFlow:
ambytes, _base = _build_conflicting_patches(gitdir)
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
wt = exc_info.value.worktree_path
# User returns from shell without resolving
ecode, wt_gitdir = b4.git_run_command(
- wt, ['rev-parse', '--git-dir'],
- logstderr=True, rundir=wt)
+ wt, ['rev-parse', '--git-dir'], logstderr=True, rundir=wt
+ )
assert ecode == 0
rebase_apply = os.path.join(wt_gitdir.strip(), 'rebase-apply')
assert os.path.isdir(rebase_apply)
@@ -375,15 +381,15 @@ class TestConflictResolutionFlow:
ambytes, _base = _build_conflicting_patches(gitdir)
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
wt = exc_info.value.worktree_path
# Before: sparse checkout may hide files
# (the worktree was created with sparse-checkout set to empty)
- b4.git_run_command(wt, ['sparse-checkout', 'disable'],
- logstderr=True, rundir=wt)
+ b4.git_run_command(
+ wt, ['sparse-checkout', 'disable'], logstderr=True, rundir=wt
+ )
# All repo files should now be visible
assert os.path.exists(os.path.join(wt, 'file1.txt'))
@@ -397,20 +403,17 @@ class TestConflictResolutionFlow:
ambytes, _base = _build_conflicting_patches(gitdir)
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
wt = exc_info.value.worktree_path
# Resolve and fetch
- b4.git_run_command(wt, ['sparse-checkout', 'disable'],
- logstderr=True, rundir=wt)
- b4.git_run_command(wt, ['checkout', '--theirs', '.'],
- logstderr=True, rundir=wt)
- b4.git_run_command(wt, ['add', '-A'],
- logstderr=True, rundir=wt)
- b4.git_run_command(wt, ['am', '--continue'],
- logstderr=True, rundir=wt)
+ b4.git_run_command(
+ wt, ['sparse-checkout', 'disable'], logstderr=True, rundir=wt
+ )
+ b4.git_run_command(wt, ['checkout', '--theirs', '.'], logstderr=True, rundir=wt)
+ b4.git_run_command(wt, ['add', '-A'], logstderr=True, rundir=wt)
+ b4.git_run_command(wt, ['am', '--continue'], logstderr=True, rundir=wt)
b4.git_run_command(gitdir, ['fetch', wt], logstderr=True)
# Rewrite FETCH_HEAD (as the TUI handler does)
@@ -438,8 +441,9 @@ class TestDirectAmConflictFlow:
ambytes, _base = _build_conflicting_patches(gitdir)
# Run git-am directly (as _do_take_am does)
- ecode, _out = b4.git_run_command(gitdir, ['am', '-3'],
- stdin=ambytes, logstderr=True)
+ ecode, _out = b4.git_run_command(
+ gitdir, ['am', '-3'], stdin=ambytes, logstderr=True
+ )
assert ecode != 0
# rebase-apply should exist
@@ -447,11 +451,9 @@ class TestDirectAmConflictFlow:
assert os.path.isdir(rebase_apply)
# Resolve: accept theirs and continue
- b4.git_run_command(gitdir, ['checkout', '--theirs', '.'],
- logstderr=True)
+ b4.git_run_command(gitdir, ['checkout', '--theirs', '.'], logstderr=True)
b4.git_run_command(gitdir, ['add', '-A'], logstderr=True)
- ecode, _out = b4.git_run_command(gitdir, ['am', '--continue'],
- logstderr=True)
+ ecode, _out = b4.git_run_command(gitdir, ['am', '--continue'], logstderr=True)
assert ecode == 0
assert not os.path.isdir(rebase_apply)
@@ -459,8 +461,9 @@ class TestDirectAmConflictFlow:
"""Direct git-am -3 conflict, user aborts."""
ambytes, _base = _build_conflicting_patches(gitdir)
- ecode, _out = b4.git_run_command(gitdir, ['am', '-3'],
- stdin=ambytes, logstderr=True)
+ ecode, _out = b4.git_run_command(
+ gitdir, ['am', '-3'], stdin=ambytes, logstderr=True
+ )
assert ecode != 0
# rebase-apply is present (handler detects this)
@@ -476,6 +479,7 @@ class TestDirectAmConflictFlow:
# Tier 4 — Shazam state machine tests
# ---------------------------------------------------------------------------
+
def _build_multi_patch_conflict(gitdir: str) -> Tuple[bytes, str]:
"""Create a 3-patch mbox where patches 1-2 are clean but patch 3 conflicts.
@@ -522,8 +526,9 @@ def _build_multi_patch_conflict(gitdir: str) -> Tuple[bytes, str]:
return mbox.encode(), base
-def _make_shazam_state(common_dir: str,
- state: Optional[Dict[str, Any]] = None) -> Tuple[str, str]:
+def _make_shazam_state(
+ common_dir: str, state: Optional[Dict[str, Any]] = None
+) -> Tuple[str, str]:
"""Create shazam state file and patches dir.
Returns (state_file_path, patches_dir_path).
@@ -546,10 +551,11 @@ class TestLoadShazamState:
assert common_dir is not None
state_file, patches_dir = _make_shazam_state(common_dir)
try:
- _topdir, _cdir, sf, loaded = b4.mbox._load_shazam_state(
- require_state=True)
- assert loaded == {'origin': 'https://example.com',
- 'merge_flags': '--signoff'}
+ _topdir, _cdir, sf, loaded = b4.mbox._load_shazam_state(require_state=True)
+ assert loaded == {
+ 'origin': 'https://example.com',
+ 'merge_flags': '--signoff',
+ }
assert sf == state_file
finally:
os.unlink(state_file)
@@ -561,8 +567,7 @@ class TestLoadShazamState:
assert exc_info.value.code == 1
def test_optional_state_returns_none(self, gitdir: str) -> None:
- _topdir, _cdir, _sf, loaded = b4.mbox._load_shazam_state(
- require_state=False)
+ _topdir, _cdir, _sf, loaded = b4.mbox._load_shazam_state(require_state=False)
assert loaded is None
def test_missing_patches_dir_exits(self, gitdir: str) -> None:
@@ -638,8 +643,7 @@ class TestStartMergeResolve:
assert common_dir is not None
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
state = {
'origin': 'https://example.com',
@@ -651,8 +655,7 @@ class TestStartMergeResolve:
# _start_merge_resolve exits(1) because remaining patch 3 conflicts
with pytest.raises(SystemExit) as exit_info:
- b4.mbox._start_merge_resolve(
- gitdir, exc_info.value, common_dir, state)
+ b4.mbox._start_merge_resolve(gitdir, exc_info.value, common_dir, state)
assert exit_info.value.code == 1
# State file and patches dir should exist
@@ -680,8 +683,7 @@ class TestStartMergeResolve:
# Step 1: trigger conflict
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
state = {
'origin': 'https://example.com',
@@ -694,8 +696,7 @@ class TestStartMergeResolve:
# Step 2: _start_merge_resolve extracts patches, starts merge,
# applies remaining patch 3 which conflicts -> exit(1)
with pytest.raises(SystemExit):
- b4.mbox._start_merge_resolve(
- gitdir, exc_info.value, common_dir, state)
+ b4.mbox._start_merge_resolve(gitdir, exc_info.value, common_dir, state)
# Step 3: resolve the conflict (accept any content)
with open(os.path.join(gitdir, 'file1.txt'), 'w') as fh:
@@ -709,12 +710,14 @@ class TestStartMergeResolve:
# Step 5: verify merge commit was created
ecode, _log_out = b4.git_run_command(
- gitdir, ['log', '--oneline', '-1', '--format=%s'])
+ gitdir, ['log', '--oneline', '-1', '--format=%s']
+ )
assert ecode == 0
# The commit was made with -F (the merge template content)
# Just verify a commit exists on top of our branch
ecode, parents = b4.git_run_command(
- gitdir, ['rev-list', '--parents', '-1', 'HEAD'])
+ gitdir, ['rev-list', '--parents', '-1', 'HEAD']
+ )
assert ecode == 0
# Merge commit has 2 parents
parent_list = parents.strip().split()
@@ -733,8 +736,7 @@ class TestStartMergeResolve:
assert common_dir is not None
with pytest.raises(b4.AmConflictError) as exc_info:
- b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD',
- am_flags=['-3'])
+ b4.git_fetch_am_into_repo(gitdir, ambytes, at_base='HEAD', am_flags=['-3'])
state = {
'origin': 'https://example.com',
@@ -745,8 +747,7 @@ class TestStartMergeResolve:
}
with pytest.raises(SystemExit):
- b4.mbox._start_merge_resolve(
- gitdir, exc_info.value, common_dir, state)
+ b4.mbox._start_merge_resolve(gitdir, exc_info.value, common_dir, state)
# Abort instead of resolving
cmdargs = argparse.Namespace()
diff --git a/src/tests/test_tui_bugs.py b/src/tests/test_tui_bugs.py
index 4cab381..a772be7 100644
--- a/src/tests/test_tui_bugs.py
+++ b/src/tests/test_tui_bugs.py
@@ -8,6 +8,7 @@
Tests the pure-logic functions in _import.py and _tui.py that don't
need Textual, git-bug, or network access.
"""
+
from datetime import datetime, timezone
from typing import Set
from unittest import mock
@@ -94,6 +95,7 @@ def make_summary(
# _import.py tests
# ===========================================================================
+
class TestParseCommentHeader:
def test_extracts_from(self) -> None:
text = 'From: Alice <alice@example.com>\nDate: Mon, 1 Jan 2026\n\nBody'
@@ -188,7 +190,9 @@ class TestFormatComment:
'In-Reply-To': '<parent@test.com>',
}.get(h)
with mock.patch('b4.LoreMessage.clean_header', side_effect=lambda x: x):
- with mock.patch('b4.LoreMessage.get_payload', return_value=('Body text', 'utf-8')):
+ with mock.patch(
+ 'b4.LoreMessage.get_payload', return_value=('Body text', 'utf-8')
+ ):
result = format_comment(msg)
assert 'From: Alice <alice@test.com>' in result
assert 'Message-ID: <abc@test.com>' in result
@@ -201,7 +205,9 @@ class TestFormatComment:
'Message-ID': '<abc@test.com>',
}.get(h)
with mock.patch('b4.LoreMessage.clean_header', side_effect=lambda x: x):
- with mock.patch('b4.LoreMessage.get_payload', return_value=('Body', 'utf-8')):
+ with mock.patch(
+ 'b4.LoreMessage.get_payload', return_value=('Body', 'utf-8')
+ ):
result = format_comment(msg, scope='no-parent')
assert 'X-B4-Bug-Scope: no-parent' in result
@@ -210,6 +216,7 @@ class TestFormatComment:
# _tui.py tests
# ===========================================================================
+
class TestLabelColor:
def test_deterministic(self) -> None:
c1 = label_color('review')
@@ -248,6 +255,7 @@ class TestBugTier:
def test_closed_is_tier_2(self) -> None:
from ezgb import Status
+
bug = make_bug(status=Status.CLOSED)
assert _bug_tier(bug) == 2
@@ -275,6 +283,7 @@ class TestBugLifecycle:
def test_closed_no_lifecycle(self) -> None:
from ezgb import Status
+
bug = make_bug(status=Status.CLOSED)
assert _bug_lifecycle(bug) == '\u00d7' # ×
@@ -296,12 +305,16 @@ class TestBugLastActivity:
def test_summary_uses_edited_at(self) -> None:
from ezgb import BugSummary, Status
+
edited_time = datetime(2026, 4, 1, tzinfo=timezone.utc)
s = BugSummary(
- id='a' * 64, title='Test', status=Status.OPEN,
+ id='a' * 64,
+ title='Test',
+ status=Status.OPEN,
creator_id='b' * 64,
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
- labels=frozenset(), comment_count=1,
+ labels=frozenset(),
+ comment_count=1,
edited_at=edited_time,
)
assert _bug_last_activity(s) == edited_time
@@ -314,16 +327,19 @@ class TestRelativeTime:
def test_minutes(self) -> None:
from datetime import timedelta
+
t = datetime.now(tz=timezone.utc) - timedelta(minutes=5)
assert '5m ago' == _relative_time(t)
def test_hours(self) -> None:
from datetime import timedelta
+
t = datetime.now(tz=timezone.utc) - timedelta(hours=3)
assert '3h ago' == _relative_time(t)
def test_days(self) -> None:
from datetime import timedelta
+
t = datetime.now(tz=timezone.utc) - timedelta(days=7)
assert '7d ago' == _relative_time(t)
@@ -343,12 +359,14 @@ class TestMatchesLimit:
def test_status_filter_open(self) -> None:
from ezgb import Status
+
bug = make_bug(status=Status.OPEN)
assert BugListApp._matches_limit(bug, 's:open') is True
assert BugListApp._matches_limit(bug, 's:closed') is False
def test_status_filter_closed(self) -> None:
from ezgb import Status
+
bug = make_bug(status=Status.CLOSED)
assert BugListApp._matches_limit(bug, 's:closed') is True
assert BugListApp._matches_limit(bug, 's:open') is False
@@ -372,6 +390,7 @@ class TestMatchesLimit:
def test_summary_works(self) -> None:
from ezgb import Status
+
s = make_summary(
title='Network bug',
status=Status.OPEN,
@@ -440,27 +459,30 @@ class TestParseMsgidForImport:
def test_bare_msgid(self) -> None:
import b4
+
result = b4.parse_msgid('abc123@example.com')
assert result == 'abc123@example.com'
def test_angle_bracketed(self) -> None:
import b4
+
result = b4.parse_msgid('<abc123@example.com>')
assert result == 'abc123@example.com'
def test_lore_url(self) -> None:
import b4
- result = b4.parse_msgid(
- 'https://lore.kernel.org/all/abc123@example.com/')
+
+ result = b4.parse_msgid('https://lore.kernel.org/all/abc123@example.com/')
assert result == 'abc123@example.com'
def test_patch_msgid_link(self) -> None:
import b4
- result = b4.parse_msgid(
- 'https://patch.msgid.link/abc123@example.com')
+
+ result = b4.parse_msgid('https://patch.msgid.link/abc123@example.com')
assert result == 'abc123@example.com'
def test_garbage_has_no_at(self) -> None:
import b4
+
result = b4.parse_msgid('not-a-msgid')
assert '@' not in result
diff --git a/src/tests/test_tui_modals.py b/src/tests/test_tui_modals.py
index e0e6f3f..8f16781 100644
--- a/src/tests/test_tui_modals.py
+++ b/src/tests/test_tui_modals.py
@@ -9,6 +9,7 @@ Uses Textual's built-in ``App.run_test()`` / ``Pilot`` harness so the
tests run without a real terminal. Only lightweight, self-contained
modals are exercised here — no database, network, or git needed.
"""
+
from typing import Any, Dict, List, Optional, Tuple
import pytest
@@ -35,6 +36,7 @@ from b4.review_tui._modals import (
# older builds (e.g. Fedora 43 package) still use Static.renderable.
# ---------------------------------------------------------------------------
+
def _static_text(widget: Any) -> str:
"""Return the text content of a Static widget across Textual versions."""
if hasattr(widget, 'content'):
@@ -46,6 +48,7 @@ def _static_text(widget: Any) -> str:
# Minimal host app — just enough to push modal screens onto
# ---------------------------------------------------------------------------
+
class ModalTestApp(App[None]):
"""Bare app that serves as a host for pushing modal screens."""
@@ -57,6 +60,7 @@ class ModalTestApp(App[None]):
# HelpScreen
# ---------------------------------------------------------------------------
+
class TestHelpScreen:
"""Tests for the HelpScreen modal."""
@@ -124,12 +128,21 @@ class TestHelpScreen:
await pilot.pause()
assert isinstance(app.screen, HelpScreen)
- for key in ('j', 'k', 'down', 'up', 'space', 'backspace',
- 'pagedown', 'pageup'):
+ for key in (
+ 'j',
+ 'k',
+ 'down',
+ 'up',
+ 'space',
+ 'backspace',
+ 'pagedown',
+ 'pageup',
+ ):
await pilot.press(key)
await pilot.pause()
- assert isinstance(app.screen, HelpScreen), \
+ assert isinstance(app.screen, HelpScreen), (
f'{key!r} unexpectedly closed the help screen'
+ )
@pytest.mark.asyncio
async def test_content_rendered(self) -> None:
@@ -149,6 +162,7 @@ class TestHelpScreen:
# ConfirmScreen
# ---------------------------------------------------------------------------
+
class TestConfirmScreen:
"""Tests for the ConfirmScreen modal."""
@@ -201,8 +215,9 @@ class TestConfirmScreen:
# body lines + hint line + possibly title
rendered = [_static_text(s) for s in statics]
for line in body:
- assert any(line in r for r in rendered), \
+ assert any(line in r for r in rendered), (
f'{line!r} not found in rendered statics'
+ )
@pytest.mark.asyncio
async def test_subject_shown(self) -> None:
@@ -235,6 +250,7 @@ class TestConfirmScreen:
# TrailerScreen
# ---------------------------------------------------------------------------
+
class TestTrailerScreen:
"""Tests for the TrailerScreen modal."""
@@ -374,6 +390,7 @@ class TestTrailerScreen:
# NoteScreen
# ---------------------------------------------------------------------------
+
class TestNoteScreen:
"""Tests for the NoteScreen modal."""
@@ -440,6 +457,7 @@ class TestNoteScreen:
# PriorReviewScreen
# ---------------------------------------------------------------------------
+
class TestPriorReviewScreen:
"""Tests for the PriorReviewScreen modal."""
@@ -475,6 +493,7 @@ class TestPriorReviewScreen:
# RevisionChoiceScreen
# ---------------------------------------------------------------------------
+
class TestRevisionChoiceScreen:
"""Tests for the RevisionChoiceScreen modal."""
@@ -532,6 +551,7 @@ class TestRevisionChoiceScreen:
# SnoozeScreen
# ---------------------------------------------------------------------------
+
class TestSnoozeScreen:
"""Tests for the SnoozeScreen modal."""
@@ -667,6 +687,7 @@ class TestSnoozeScreen:
# SetStateScreen
# ---------------------------------------------------------------------------
+
class TestSetStateScreen:
"""Tests for the SetStateScreen modal."""
@@ -756,6 +777,7 @@ class TestSetStateScreen:
# LimitScreen
# ---------------------------------------------------------------------------
+
class TestLimitScreen:
"""Tests for the LimitScreen modal."""
@@ -819,6 +841,7 @@ class TestLimitScreen:
# ActionScreen
# ---------------------------------------------------------------------------
+
class TestActionScreen:
"""Tests for the ActionScreen modal."""
@@ -888,7 +911,9 @@ class TestActionScreen:
results: List[Optional[str]] = []
async with app.run_test() as pilot:
- app.push_screen(ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append)
+ app.push_screen(
+ ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append
+ )
await pilot.pause()
# 'T' is the shortcut for 'take'
@@ -902,7 +927,9 @@ class TestActionScreen:
results: List[Optional[str]] = []
async with app.run_test() as pilot:
- app.push_screen(ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append)
+ app.push_screen(
+ ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append
+ )
await pilot.pause()
await pilot.press('r')
@@ -915,7 +942,9 @@ class TestActionScreen:
results: List[Optional[str]] = []
async with app.run_test() as pilot:
- app.push_screen(ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append)
+ app.push_screen(
+ ActionScreen(self._actions(), shortcuts=self._SHORTCUTS), results.append
+ )
await pilot.pause()
await pilot.press('x')
@@ -927,6 +956,7 @@ class TestActionScreen:
# UpdateRevisionScreen
# ---------------------------------------------------------------------------
+
class TestUpdateRevisionScreen:
"""Tests for the UpdateRevisionScreen modal."""
diff --git a/src/tests/test_tui_review.py b/src/tests/test_tui_review.py
index 3222989..fb6fae0 100644
--- a/src/tests/test_tui_review.py
+++ b/src/tests/test_tui_review.py
@@ -8,6 +8,7 @@
Tests the shell-return reconciliation logic that detects and handles
cosmetic commit edits (e.g. reworded subjects via git rebase -i).
"""
+
from typing import Any, Dict, List, Tuple
import pytest
@@ -20,6 +21,7 @@ from b4.review_tui._review_app import ReviewApp
# Helpers
# ---------------------------------------------------------------------------
+
def _create_review_branch_with_patches(
gitdir: str,
change_id: str,
@@ -53,8 +55,7 @@ def _create_review_branch_with_patches(
# Create patch commits
patch_shas: List[str] = []
for msg in patch_messages:
- ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', msg])
+ ecode, _ = b4.git_run_command(gitdir, ['commit', '--allow-empty', '-m', msg])
assert ecode == 0
ecode, sha = b4.git_run_command(gitdir, ['rev-parse', 'HEAD'])
assert ecode == 0
@@ -63,10 +64,12 @@ def _create_review_branch_with_patches(
# Build tracking metadata
patches_meta: List[Dict[str, Any]] = []
for i, _sha in enumerate(patch_shas):
- patches_meta.append({
- 'header-info': {'msgid': f'{change_id}-patch{i + 1}@example.com'},
- 'followups': [],
- })
+ patches_meta.append(
+ {
+ 'header-info': {'msgid': f'{change_id}-patch{i + 1}@example.com'},
+ 'followups': [],
+ }
+ )
trk: Dict[str, Any] = {
'series': {
@@ -90,8 +93,7 @@ def _create_review_branch_with_patches(
commit_msg = f'{subject}\n\n{b4.review.make_review_magic_json(trk)}'
# Create tracking commit (empty)
- ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', commit_msg])
+ ecode, _ = b4.git_run_command(gitdir, ['commit', '--allow-empty', '-m', commit_msg])
assert ecode == 0
return branch_name, patch_shas
@@ -110,18 +112,17 @@ def _build_session(gitdir: str, branch_name: str) -> Dict[str, Any]:
else:
range_spec = f'{base_commit}..{branch_name}~1'
- ecode, out = b4.git_run_command(
- gitdir, ['rev-list', '--reverse', range_spec])
+ ecode, out = b4.git_run_command(gitdir, ['rev-list', '--reverse', range_spec])
assert ecode == 0
commit_shas = out.strip().splitlines()
ecode, out = b4.git_run_command(
- gitdir, ['log', '--reverse', '--format=%s', range_spec])
+ gitdir, ['log', '--reverse', '--format=%s', range_spec]
+ )
assert ecode == 0
commit_subjects = out.strip().splitlines()
- ecode, out = b4.git_run_command(
- gitdir, ['rev-parse', '--short', 'HEAD'])
+ ecode, out = b4.git_run_command(gitdir, ['rev-parse', '--short', 'HEAD'])
abbrev_len = len(out.strip()) if ecode == 0 else 7
sha_map: Dict[str, Tuple[str, int]] = {}
@@ -142,7 +143,7 @@ def _build_session(gitdir: str, branch_name: str) -> Dict[str, Any]:
'commit_subjects': commit_subjects,
'sha_map': sha_map,
'abbrev_len': abbrev_len,
- 'default_identity': f"{usercfg.get('name', 'Test')} <{usercfg.get('email', 'test@example.com')}>",
+ 'default_identity': f'{usercfg.get("name", "Test")} <{usercfg.get("email", "test@example.com")}>',
'usercfg': usercfg,
'cover_subject_clean': series.get('subject', ''),
}
@@ -150,29 +151,26 @@ def _build_session(gitdir: str, branch_name: str) -> Dict[str, Any]:
def _save_tracking_msg(gitdir: str) -> str:
"""Save the tracking commit message from HEAD."""
- ecode, msg = b4.git_run_command(
- gitdir, ['log', '-1', '--format=%B', 'HEAD'])
+ ecode, msg = b4.git_run_command(gitdir, ['log', '-1', '--format=%B', 'HEAD'])
assert ecode == 0
return msg.strip()
-def _rewrite_patches(gitdir: str, base_sha: str,
- new_subjects: List[str], trk_msg: str) -> None:
+def _rewrite_patches(
+ gitdir: str, base_sha: str, new_subjects: List[str], trk_msg: str
+) -> None:
"""Reset to base and recreate patches + tracking commit.
Hard-resets to *base_sha*, creates one --allow-empty commit per
subject in *new_subjects*, then recreates the tracking commit
from *trk_msg*.
"""
- ecode, _ = b4.git_run_command(
- gitdir, ['reset', '--hard', base_sha])
+ ecode, _ = b4.git_run_command(gitdir, ['reset', '--hard', base_sha])
assert ecode == 0
for subj in new_subjects:
- ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', subj])
+ ecode, _ = b4.git_run_command(gitdir, ['commit', '--allow-empty', '-m', subj])
assert ecode == 0
- ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', trk_msg])
+ ecode, _ = b4.git_run_command(gitdir, ['commit', '--allow-empty', '-m', trk_msg])
assert ecode == 0
@@ -180,6 +178,7 @@ def _rewrite_patches(gitdir: str, base_sha: str,
# Tests
# ---------------------------------------------------------------------------
+
class TestReconcileAfterShell:
"""Tests for _reconcile_after_shell tracking fixup."""
@@ -187,7 +186,8 @@ class TestReconcileAfterShell:
async def test_no_changes(self, gitdir: str) -> None:
"""No-op when commits are unchanged after shell return."""
branch, patch_shas = _create_review_branch_with_patches(
- gitdir, 'reconcile-noop', ['patch 1', 'patch 2'])
+ gitdir, 'reconcile-noop', ['patch 1', 'patch 2']
+ )
session = _build_session(gitdir, branch)
app = ReviewApp(session)
@@ -203,8 +203,8 @@ class TestReconcileAfterShell:
async def test_reworded_commits(self, gitdir: str) -> None:
"""Tracking is updated after commit messages are reworded."""
branch, patch_shas = _create_review_branch_with_patches(
- gitdir, 'reconcile-reword',
- ['original subject 1', 'original subject 2'])
+ gitdir, 'reconcile-reword', ['original subject 1', 'original subject 2']
+ )
session = _build_session(gitdir, branch)
base_sha = session['base_commit']
@@ -216,9 +216,9 @@ class TestReconcileAfterShell:
# Simulate rewording both commits (as git rebase -i would)
trk_msg = _save_tracking_msg(gitdir)
- _rewrite_patches(gitdir, base_sha,
- ['reworded subject 1', 'reworded subject 2'],
- trk_msg)
+ _rewrite_patches(
+ gitdir, base_sha, ['reworded subject 1', 'reworded subject 2'], trk_msg
+ )
app._reconcile_after_shell(old_shas)
@@ -229,8 +229,7 @@ class TestReconcileAfterShell:
assert app._series['first-patch-commit'] == app._commit_shas[0]
assert app._series['first-patch-commit'] != patch_shas[0]
# Subjects should reflect the reword
- assert app._commit_subjects == ['reworded subject 1',
- 'reworded subject 2']
+ assert app._commit_subjects == ['reworded subject 1', 'reworded subject 2']
# sha_map should be updated
assert len(app._sha_map) == 2
@@ -238,8 +237,8 @@ class TestReconcileAfterShell:
async def test_single_reword_preserves_unchanged(self, gitdir: str) -> None:
"""Only the reworded commit gets a new SHA; unchanged ones keep theirs."""
branch, _patch_shas = _create_review_branch_with_patches(
- gitdir, 'reconcile-partial',
- ['keep this one', 'change this one'])
+ gitdir, 'reconcile-partial', ['keep this one', 'change this one']
+ )
session = _build_session(gitdir, branch)
app = ReviewApp(session)
@@ -250,14 +249,15 @@ class TestReconcileAfterShell:
# Reword only the second commit: reset to after first patch,
# then recreate second + tracking
trk_msg = _save_tracking_msg(gitdir)
- ecode, _ = b4.git_run_command(
- gitdir, ['reset', '--hard', old_shas[0]])
+ ecode, _ = b4.git_run_command(gitdir, ['reset', '--hard', old_shas[0]])
assert ecode == 0
ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', 'changed subject 2'])
+ gitdir, ['commit', '--allow-empty', '-m', 'changed subject 2']
+ )
assert ecode == 0
ecode, _ = b4.git_run_command(
- gitdir, ['commit', '--allow-empty', '-m', trk_msg])
+ gitdir, ['commit', '--allow-empty', '-m', trk_msg]
+ )
assert ecode == 0
app._reconcile_after_shell(old_shas)
@@ -273,8 +273,8 @@ class TestReconcileAfterShell:
async def test_patch_count_mismatch(self, gitdir: str) -> None:
"""Warns and does not update when patch count changes."""
branch, patch_shas = _create_review_branch_with_patches(
- gitdir, 'reconcile-mismatch',
- ['patch 1', 'patch 2', 'patch 3'])
+ gitdir, 'reconcile-mismatch', ['patch 1', 'patch 2', 'patch 3']
+ )
session = _build_session(gitdir, branch)
base_sha = session['base_commit']
@@ -286,9 +286,7 @@ class TestReconcileAfterShell:
# Simulate squashing: recreate with fewer patches
trk_msg = _save_tracking_msg(gitdir)
- _rewrite_patches(gitdir, base_sha,
- ['patch 1', 'squashed 2+3'],
- trk_msg)
+ _rewrite_patches(gitdir, base_sha, ['patch 1', 'squashed 2+3'], trk_msg)
# Reconcile should NOT update tracking
app._reconcile_after_shell(old_shas)
@@ -301,8 +299,8 @@ class TestReconcileAfterShell:
async def test_tracking_commit_persisted(self, gitdir: str) -> None:
"""The on-disk tracking commit is amended with new first-patch-commit."""
branch, _patch_shas = _create_review_branch_with_patches(
- gitdir, 'reconcile-persist',
- ['persist patch 1', 'persist patch 2'])
+ gitdir, 'reconcile-persist', ['persist patch 1', 'persist patch 2']
+ )
session = _build_session(gitdir, branch)
base_sha = session['base_commit']
@@ -313,9 +311,9 @@ class TestReconcileAfterShell:
# Reword both patches
trk_msg = _save_tracking_msg(gitdir)
- _rewrite_patches(gitdir, base_sha,
- ['reworded persist 1', 'reworded persist 2'],
- trk_msg)
+ _rewrite_patches(
+ gitdir, base_sha, ['reworded persist 1', 'reworded persist 2'], trk_msg
+ )
app._reconcile_after_shell(old_shas)
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 80004e8..76ff353 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -10,6 +10,7 @@ Uses real SQLite databases (via b4.review.tracking) and git repos
core user workflows: series listing, navigation, filtering,
status transitions, and modal interactions.
"""
+
import pathlib
from typing import Any, Dict, List, Optional
from unittest.mock import patch
@@ -36,6 +37,7 @@ from b4.review_tui._tracking_app import TrackedSeriesItem, TrackingApp
# older builds (e.g. Fedora 43 package) still use Static.renderable.
# ---------------------------------------------------------------------------
+
def _static_text(widget: Any) -> str:
"""Return the text content of a Static widget across Textual versions."""
if hasattr(widget, 'content'):
@@ -47,6 +49,7 @@ def _static_text(widget: Any) -> str:
# Helpers
# ---------------------------------------------------------------------------
+
def _seed_db(identifier: str, series_list: List[Dict[str, Any]]) -> None:
"""Create and populate a tracking database with test series."""
conn = tracking.init_db(identifier)
@@ -76,20 +79,27 @@ def _seed_db(identifier: str, series_list: List[Dict[str, Any]]) -> None:
conn.execute(
'UPDATE series SET message_count = ?, seen_message_count = ? '
'WHERE change_id = ? AND revision = ?',
- (mc, s.get('seen_message_count', mc),
- s['change_id'], s.get('revision', 1)),
+ (
+ mc,
+ s.get('seen_message_count', mc),
+ s['change_id'],
+ s.get('revision', 1),
+ ),
)
conn.commit()
conn.close()
-def _create_review_branch(gitdir: str, change_id: str,
- identifier: str = 'test-project',
- revision: int = 1,
- status: str = 'reviewing',
- subject: str = 'Test series',
- sender_name: str = 'Test Author',
- sender_email: str = 'test@example.com') -> str:
+def _create_review_branch(
+ gitdir: str,
+ change_id: str,
+ identifier: str = 'test-project',
+ revision: int = 1,
+ status: str = 'reviewing',
+ subject: str = 'Test series',
+ sender_name: str = 'Test Author',
+ sender_email: str = 'test@example.com',
+) -> str:
"""Create a fake b4 review branch with a proper tracking commit.
Returns the branch name.
@@ -128,13 +138,15 @@ def _create_review_branch(gitdir: str, change_id: str,
assert ecode == 0
tree = tree.strip()
ecode, new_sha = b4.git_run_command(
- gitdir, ['commit-tree', tree, '-p', base_sha],
+ gitdir,
+ ['commit-tree', tree, '-p', base_sha],
stdin=commit_msg.encode(),
)
assert ecode == 0
new_sha = new_sha.strip()
ecode, _ = b4.git_run_command(
- gitdir, ['update-ref', f'refs/heads/{branch_name}', new_sha])
+ gitdir, ['update-ref', f'refs/heads/{branch_name}', new_sha]
+ )
assert ecode == 0
return branch_name
@@ -177,6 +189,7 @@ SAMPLE_SERIES: List[Dict[str, Any]] = [
# Tests
# ---------------------------------------------------------------------------
+
class TestTrackingAppStartup:
"""Tests for the TrackingApp startup and series listing."""
@@ -204,7 +217,9 @@ class TestTrackingAppStartup:
assert len(list(lv.children)) == 3
@pytest.mark.asyncio
- async def test_title_shows_identifier_and_count(self, tmp_path: pathlib.Path) -> None:
+ async def test_title_shows_identifier_and_count(
+ self, tmp_path: pathlib.Path
+ ) -> None:
_seed_db('test-title', SAMPLE_SERIES)
app = TrackingApp('test-title')
@@ -292,6 +307,7 @@ class TestTrackingLimit:
assert isinstance(app.screen, LimitScreen)
from textual.widgets import Input
+
inp = app.screen.query_one('#limit-input', Input)
inp.value = 'drm'
await pilot.press('enter')
@@ -315,6 +331,7 @@ class TestTrackingLimit:
await pilot.pause()
from textual.widgets import Input
+
inp = app.screen.query_one('#limit-input', Input)
inp.value = 'Charlie'
await pilot.press('enter')
@@ -337,13 +354,16 @@ class TestTrackingLimit:
await pilot.press('l')
await pilot.pause()
from textual.widgets import Input
+
inp = app.screen.query_one('#limit-input', Input)
inp.value = 'alpha'
await pilot.press('enter')
await pilot.pause()
lv = app.query_one('#tracking-list', ListView)
- assert len([c for c in lv.children if isinstance(c, TrackedSeriesItem)]) == 1
+ assert (
+ len([c for c in lv.children if isinstance(c, TrackedSeriesItem)]) == 1
+ )
# Clear the filter
await pilot.press('l')
@@ -354,7 +374,9 @@ class TestTrackingLimit:
await pilot.pause()
lv = app.query_one('#tracking-list', ListView)
- assert len([c for c in lv.children if isinstance(c, TrackedSeriesItem)]) == 3
+ assert (
+ len([c for c in lv.children if isinstance(c, TrackedSeriesItem)]) == 3
+ )
@pytest.mark.asyncio
async def test_limit_title_shows_count(self, tmp_path: pathlib.Path) -> None:
@@ -367,6 +389,7 @@ class TestTrackingLimit:
await pilot.press('l')
await pilot.pause()
from textual.widgets import Input
+
inp = app.screen.query_one('#limit-input', Input)
inp.value = 'alpha'
await pilot.press('enter')
@@ -382,19 +405,22 @@ class TestTrackingLimitPrefixes:
@pytest.mark.asyncio
async def test_limit_by_status(self, tmp_path: pathlib.Path) -> None:
"""s:snoozed should show only snoozed series."""
- _seed_db('test-limit-status', [
- {
- 'change_id': 'ls-new',
- 'subject': '[PATCH] new one',
- 'message_id': 'lsn@ex.com',
- },
- {
- 'change_id': 'ls-snoozed',
- 'subject': '[PATCH] snoozed one',
- 'status': 'snoozed',
- 'message_id': 'lss@ex.com',
- },
- ])
+ _seed_db(
+ 'test-limit-status',
+ [
+ {
+ 'change_id': 'ls-new',
+ 'subject': '[PATCH] new one',
+ 'message_id': 'lsn@ex.com',
+ },
+ {
+ 'change_id': 'ls-snoozed',
+ 'subject': '[PATCH] snoozed one',
+ 'status': 'snoozed',
+ 'message_id': 'lss@ex.com',
+ },
+ ],
+ )
app = TrackingApp('test-limit-status')
async with app.run_test(size=(120, 30)) as pilot:
@@ -402,6 +428,7 @@ class TestTrackingLimitPrefixes:
await pilot.press('l')
await pilot.pause()
from textual.widgets import Input
+
inp = app.screen.query_one('#limit-input', Input)
inp.value = 's:snoozed'
await pilot.press('enter')
@@ -444,7 +471,9 @@ class TestTrackingStatusGroups:
"""Tests for status grouping and display."""
@pytest.mark.asyncio
- async def test_actionable_before_non_actionable(self, tmp_path: pathlib.Path) -> None:
+ async def test_actionable_before_non_actionable(
+ self, tmp_path: pathlib.Path
+ ) -> None:
"""Actionable series (new) should appear before non-actionable (snoozed).
We use only statuses that don't require a real review branch
@@ -546,6 +575,7 @@ class TestTrackingQuit:
# Tests with real git repos (review branches)
# ---------------------------------------------------------------------------
+
class TestTrackingWithReviewBranch:
"""Tests that use the gitdir fixture for real review branches."""
@@ -555,12 +585,17 @@ class TestTrackingWithReviewBranch:
identifier = 'test-reviewing'
change_id = 'test-review-branch-1'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] series with review branch',
- 'status': 'reviewing',
- 'message_id': 'review-branch-1@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] series with review branch',
+ 'status': 'reviewing',
+ 'message_id': 'review-branch-1@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -576,12 +611,17 @@ class TestTrackingWithReviewBranch:
identifier = 'test-review-exit'
change_id = 'test-exit-branch'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] exit test',
- 'status': 'reviewing',
- 'message_id': 'exit@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] exit test',
+ 'status': 'reviewing',
+ 'message_id': 'exit@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -597,12 +637,17 @@ class TestTrackingWithReviewBranch:
identifier = 'test-enter-review'
change_id = 'test-enter-branch'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] enter test',
- 'status': 'reviewing',
- 'message_id': 'enter@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] enter test',
+ 'status': 'reviewing',
+ 'message_id': 'enter@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -616,14 +661,20 @@ class TestTrackingWithReviewBranch:
"""Pressing 'r' on a waiting series should change it to reviewing."""
identifier = 'test-wait-review'
change_id = 'test-waiting-branch'
- _create_review_branch(gitdir, change_id, identifier=identifier,
- status='waiting')
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] waiting test',
- 'status': 'waiting',
- 'message_id': 'waiting@ex.com',
- }])
+ _create_review_branch(
+ gitdir, change_id, identifier=identifier, status='waiting'
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] waiting test',
+ 'status': 'waiting',
+ 'message_id': 'waiting@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -637,8 +688,8 @@ class TestTrackingWithReviewBranch:
# Verify status was updated in DB
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?',
- (change_id,))
+ 'SELECT status FROM series WHERE change_id = ?', (change_id,)
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == 'reviewing'
@@ -649,14 +700,19 @@ class TestTrackingWithReviewBranch:
identifier = 'test-seen'
change_id = 'test-seen-branch'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] seen test',
- 'status': 'reviewing',
- 'message_id': 'seen@ex.com',
- 'message_count': 10,
- 'seen_message_count': 3,
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] seen test',
+ 'status': 'reviewing',
+ 'message_id': 'seen@ex.com',
+ 'message_count': 10,
+ 'seen_message_count': 3,
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -668,7 +724,8 @@ class TestTrackingWithReviewBranch:
conn = tracking.get_db(identifier)
cursor = conn.execute(
'SELECT message_count, seen_message_count FROM series WHERE change_id = ?',
- (change_id,))
+ (change_id,),
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == row[1] # seen should equal total
@@ -680,11 +737,16 @@ class TestTrackingActionMenu:
@pytest.mark.asyncio
async def test_action_menu_for_new_series(self, tmp_path: pathlib.Path) -> None:
"""New series should show review/abandon/snooze actions."""
- _seed_db('test-action-new', [{
- 'change_id': 'new-action-1',
- 'subject': '[PATCH] new action test',
- 'message_id': 'action-new@ex.com',
- }])
+ _seed_db(
+ 'test-action-new',
+ [
+ {
+ 'change_id': 'new-action-1',
+ 'subject': '[PATCH] new action test',
+ 'message_id': 'action-new@ex.com',
+ }
+ ],
+ )
app = TrackingApp('test-action-new')
async with app.run_test(size=(120, 30)) as pilot:
@@ -696,6 +758,7 @@ class TestTrackingActionMenu:
# Check available actions
lv = app.screen.query_one('#action-list', ListView)
from b4.review_tui._modals import ActionItem
+
actions = [c.key for c in lv.children if isinstance(c, ActionItem)]
assert 'review' in actions
assert 'abandon' in actions
@@ -715,12 +778,17 @@ class TestTrackingActionMenu:
identifier = 'test-action-reviewing'
change_id = 'reviewing-action-1'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] reviewing action test',
- 'status': 'reviewing',
- 'message_id': 'action-rev@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] reviewing action test',
+ 'status': 'reviewing',
+ 'message_id': 'action-rev@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -731,6 +799,7 @@ class TestTrackingActionMenu:
lv = app.screen.query_one('#action-list', ListView)
from b4.review_tui._modals import ActionItem
+
actions = [c.key for c in lv.children if isinstance(c, ActionItem)]
assert 'take' in actions
assert 'rebase' in actions
@@ -742,12 +811,17 @@ class TestTrackingActionMenu:
@pytest.mark.asyncio
async def test_action_menu_for_snoozed(self, tmp_path: pathlib.Path) -> None:
"""Snoozed series should show unsnooze/abandon actions."""
- _seed_db('test-action-snoozed', [{
- 'change_id': 'snoozed-action-1',
- 'subject': '[PATCH] snoozed action test',
- 'status': 'snoozed',
- 'message_id': 'action-snz@ex.com',
- }])
+ _seed_db(
+ 'test-action-snoozed',
+ [
+ {
+ 'change_id': 'snoozed-action-1',
+ 'subject': '[PATCH] snoozed action test',
+ 'status': 'snoozed',
+ 'message_id': 'action-snz@ex.com',
+ }
+ ],
+ )
app = TrackingApp('test-action-snoozed')
async with app.run_test(size=(120, 30)) as pilot:
@@ -758,6 +832,7 @@ class TestTrackingActionMenu:
lv = app.screen.query_one('#action-list', ListView)
from b4.review_tui._modals import ActionItem
+
actions = [c.key for c in lv.children if isinstance(c, ActionItem)]
assert 'unsnooze' in actions
assert 'abandon' in actions
@@ -770,11 +845,16 @@ class TestTrackingActionMenu:
@pytest.mark.asyncio
async def test_enter_on_new_opens_action_menu(self, tmp_path: pathlib.Path) -> None:
"""Enter on a 'new' series should open action menu (not review)."""
- _seed_db('test-enter-new', [{
- 'change_id': 'enter-new-1',
- 'subject': '[PATCH] enter new test',
- 'message_id': 'enter-new@ex.com',
- }])
+ _seed_db(
+ 'test-enter-new',
+ [
+ {
+ 'change_id': 'enter-new-1',
+ 'subject': '[PATCH] enter new test',
+ 'message_id': 'enter-new@ex.com',
+ }
+ ],
+ )
app = TrackingApp('test-enter-new')
async with app.run_test(size=(120, 30)) as pilot:
@@ -792,20 +872,27 @@ class TestTrackingUpgradeNewSeries:
@pytest.mark.asyncio
async def test_action_menu_shows_upgrade_for_new_with_newer(
- self, tmp_path: pathlib.Path) -> None:
+ self, tmp_path: pathlib.Path
+ ) -> None:
"""New series with a newer revision available should offer upgrade."""
identifier = 'test-upgrade-new'
change_id = 'upgrade-new-1'
conn = tracking.init_db(identifier)
tracking.add_series_to_db(
- conn, change_id=change_id, revision=12,
+ conn,
+ change_id=change_id,
+ revision=12,
subject='[PATCH v12] test upgrade',
- sender_name='Test', sender_email='t@ex.com',
+ sender_name='Test',
+ sender_email='t@ex.com',
sent_at='2026-01-15T10:00:00+00:00',
- message_id='v12@ex.com', num_patches=2)
+ message_id='v12@ex.com',
+ num_patches=2,
+ )
# Add v13 to the revisions table so has_newer is set
- tracking.add_revision(conn, change_id, 13, 'v13@ex.com',
- subject='[PATCH v13] test upgrade')
+ tracking.add_revision(
+ conn, change_id, 13, 'v13@ex.com', subject='[PATCH v13] test upgrade'
+ )
conn.close()
app = TrackingApp(identifier)
@@ -816,6 +903,7 @@ class TestTrackingUpgradeNewSeries:
assert isinstance(app.screen, ActionScreen)
lv = app.screen.query_one('#action-list', ListView)
from b4.review_tui._modals import ActionItem
+
actions = [c.key for c in lv.children if isinstance(c, ActionItem)]
assert 'upgrade' in actions
assert 'review' in actions
@@ -823,13 +911,19 @@ class TestTrackingUpgradeNewSeries:
@pytest.mark.asyncio
async def test_action_menu_no_upgrade_without_newer(
- self, tmp_path: pathlib.Path) -> None:
+ self, tmp_path: pathlib.Path
+ ) -> None:
"""New series without newer revisions should not offer upgrade."""
- _seed_db('test-upgrade-none', [{
- 'change_id': 'upgrade-none-1',
- 'subject': '[PATCH] no newer test',
- 'message_id': 'only@ex.com',
- }])
+ _seed_db(
+ 'test-upgrade-none',
+ [
+ {
+ 'change_id': 'upgrade-none-1',
+ 'subject': '[PATCH] no newer test',
+ 'message_id': 'only@ex.com',
+ }
+ ],
+ )
app = TrackingApp('test-upgrade-none')
async with app.run_test(size=(120, 30)) as pilot:
@@ -839,30 +933,38 @@ class TestTrackingUpgradeNewSeries:
assert isinstance(app.screen, ActionScreen)
lv = app.screen.query_one('#action-list', ListView)
from b4.review_tui._modals import ActionItem
+
actions = [c.key for c in lv.children if isinstance(c, ActionItem)]
assert 'upgrade' not in actions
await pilot.press('escape')
@pytest.mark.asyncio
- async def test_upgrade_switches_revision(
- self, tmp_path: pathlib.Path) -> None:
+ async def test_upgrade_switches_revision(self, tmp_path: pathlib.Path) -> None:
"""Upgrade on a new series should update the DB to the newer revision."""
identifier = 'test-upgrade-switch'
change_id = 'upgrade-switch-1'
conn = tracking.init_db(identifier)
tracking.add_series_to_db(
- conn, change_id=change_id, revision=12,
+ conn,
+ change_id=change_id,
+ revision=12,
subject='[PATCH v12] switch test',
- sender_name='Test', sender_email='t@ex.com',
+ sender_name='Test',
+ sender_email='t@ex.com',
sent_at='2026-01-15T10:00:00+00:00',
- message_id='v12@ex.com', num_patches=2)
+ message_id='v12@ex.com',
+ num_patches=2,
+ )
# Set message counts so we can verify they get reset
conn.execute(
'UPDATE series SET message_count = 6, seen_message_count = 4'
- ' WHERE change_id = ?', (change_id,))
+ ' WHERE change_id = ?',
+ (change_id,),
+ )
conn.commit()
- tracking.add_revision(conn, change_id, 13, 'v13@ex.com',
- subject='[PATCH v13] switch test')
+ tracking.add_revision(
+ conn, change_id, 13, 'v13@ex.com', subject='[PATCH v13] switch test'
+ )
conn.close()
app = TrackingApp(identifier)
@@ -875,6 +977,7 @@ class TestTrackingUpgradeNewSeries:
# Select 'upgrade' — it should be in the list
lv = app.screen.query_one('#action-list', ListView)
from b4.review_tui._modals import ActionItem
+
for child in lv.children:
if isinstance(child, ActionItem) and child.key == 'upgrade':
lv.index = lv.children.index(child)
@@ -887,7 +990,9 @@ class TestTrackingUpgradeNewSeries:
cursor = conn.execute(
'SELECT revision, message_id, message_count,'
' seen_message_count FROM series'
- ' WHERE change_id = ?', (change_id,))
+ ' WHERE change_id = ?',
+ (change_id,),
+ )
row = cursor.fetchone()
conn.close()
assert row is not None
@@ -904,11 +1009,16 @@ class TestTrackingSnooze:
async def test_snooze_new_series(self, tmp_path: pathlib.Path) -> None:
"""Snoozing a new series should update the database."""
identifier = 'test-snooze'
- _seed_db(identifier, [{
- 'change_id': 'snooze-test-1',
- 'subject': '[PATCH] snooze me',
- 'message_id': 'snooze@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'snooze-test-1',
+ 'subject': '[PATCH] snooze me',
+ 'message_id': 'snooze@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -937,7 +1047,8 @@ class TestTrackingSnooze:
conn = tracking.get_db(identifier)
cursor = conn.execute(
'SELECT status, snoozed_until FROM series WHERE change_id = ?',
- ('snooze-test-1',))
+ ('snooze-test-1',),
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == 'snoozed'
@@ -947,11 +1058,16 @@ class TestTrackingSnooze:
async def test_snooze_cancel(self, tmp_path: pathlib.Path) -> None:
"""Cancelling snooze should leave the series unchanged."""
identifier = 'test-snooze-cancel'
- _seed_db(identifier, [{
- 'change_id': 'snooze-cancel-1',
- 'subject': '[PATCH] do not snooze',
- 'message_id': 'nosnooze@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'snooze-cancel-1',
+ 'subject': '[PATCH] do not snooze',
+ 'message_id': 'nosnooze@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -968,8 +1084,8 @@ class TestTrackingSnooze:
# Verify status unchanged
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?',
- ('snooze-cancel-1',))
+ 'SELECT status FROM series WHERE change_id = ?', ('snooze-cancel-1',)
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == 'new'
@@ -980,12 +1096,17 @@ class TestTrackingSnooze:
identifier = 'test-snooze-branch'
change_id = 'snooze-branch-1'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] snooze branch test',
- 'status': 'reviewing',
- 'message_id': 'snzbr@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] snooze branch test',
+ 'status': 'reviewing',
+ 'message_id': 'snzbr@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1004,15 +1125,14 @@ class TestTrackingSnooze:
# Verify DB
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?',
- (change_id,))
+ 'SELECT status FROM series WHERE change_id = ?', (change_id,)
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == 'snoozed'
# Verify tracking commit was updated
- _cover_text, trk = b4.review.load_tracking(
- gitdir, f'b4/review/{change_id}')
+ _cover_text, trk = b4.review.load_tracking(gitdir, f'b4/review/{change_id}')
assert trk['series']['status'] == 'snoozed'
assert 'snoozed' in trk['series']
assert trk['series']['snoozed']['previous_state'] == 'reviewing'
@@ -1025,20 +1145,23 @@ class TestTrackingAbandon:
async def test_abandon_new_series(self, tmp_path: pathlib.Path) -> None:
"""Abandoning a new series should remove it from the DB."""
identifier = 'test-abandon'
- _seed_db(identifier, [
- {
- 'change_id': 'keep-1',
- 'subject': '[PATCH] keep me',
- 'sent_at': '2026-03-10T11:00:00+00:00',
- 'message_id': 'keep@ex.com',
- },
- {
- 'change_id': 'abandon-1',
- 'subject': '[PATCH] abandon me',
- 'sent_at': '2026-03-10T12:00:00+00:00',
- 'message_id': 'abandon@ex.com',
- },
- ])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'keep-1',
+ 'subject': '[PATCH] keep me',
+ 'sent_at': '2026-03-10T11:00:00+00:00',
+ 'message_id': 'keep@ex.com',
+ },
+ {
+ 'change_id': 'abandon-1',
+ 'subject': '[PATCH] abandon me',
+ 'sent_at': '2026-03-10T12:00:00+00:00',
+ 'message_id': 'abandon@ex.com',
+ },
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1073,11 +1196,16 @@ class TestTrackingAbandon:
async def test_abandon_cancel(self, tmp_path: pathlib.Path) -> None:
"""Cancelling abandon should leave the series intact."""
identifier = 'test-abandon-cancel'
- _seed_db(identifier, [{
- 'change_id': 'noabandon-1',
- 'subject': '[PATCH] do not abandon',
- 'message_id': 'noabandon@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'noabandon-1',
+ 'subject': '[PATCH] do not abandon',
+ 'message_id': 'noabandon@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1094,8 +1222,8 @@ class TestTrackingAbandon:
# Still in DB
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT change_id FROM series WHERE change_id = ?',
- ('noabandon-1',))
+ 'SELECT change_id FROM series WHERE change_id = ?', ('noabandon-1',)
+ )
assert cursor.fetchone() is not None
conn.close()
@@ -1104,14 +1232,18 @@ class TestTrackingAbandon:
"""Abandoning a series with a review branch should delete the branch."""
identifier = 'test-abandon-branch'
change_id = 'abandon-branch-1'
- branch_name = _create_review_branch(
- gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] abandon with branch',
- 'status': 'reviewing',
- 'message_id': 'abr@ex.com',
- }])
+ branch_name = _create_review_branch(gitdir, change_id, identifier=identifier)
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] abandon with branch',
+ 'status': 'reviewing',
+ 'message_id': 'abr@ex.com',
+ }
+ ],
+ )
# Verify branch exists before
assert b4.git_branch_exists(gitdir, branch_name)
@@ -1135,8 +1267,8 @@ class TestTrackingAbandon:
# DB should be clean
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT change_id FROM series WHERE change_id = ?',
- (change_id,))
+ 'SELECT change_id FROM series WHERE change_id = ?', (change_id,)
+ )
assert cursor.fetchone() is None
conn.close()
@@ -1150,12 +1282,17 @@ class TestTrackingWaiting:
identifier = 'test-waiting'
change_id = 'waiting-test-1'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] wait for v2',
- 'status': 'reviewing',
- 'message_id': 'wait@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] wait for v2',
+ 'status': 'reviewing',
+ 'message_id': 'wait@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1170,15 +1307,14 @@ class TestTrackingWaiting:
# Verify DB status
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?',
- (change_id,))
+ 'SELECT status FROM series WHERE change_id = ?', (change_id,)
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == 'waiting'
# Verify tracking commit
- _cover_text, trk = b4.review.load_tracking(
- gitdir, f'b4/review/{change_id}')
+ _cover_text, trk = b4.review.load_tracking(gitdir, f'b4/review/{change_id}')
assert trk['series']['status'] == 'waiting'
@pytest.mark.asyncio
@@ -1186,11 +1322,16 @@ class TestTrackingWaiting:
"""Marking a new (unimported) series as waiting should update DB only."""
identifier = 'test-new-waiting'
change_id = 'new-waiting-1'
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] needs v2',
- 'message_id': 'newwait@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] needs v2',
+ 'message_id': 'newwait@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1205,8 +1346,8 @@ class TestTrackingWaiting:
# Verify DB status changed
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?',
- (change_id,))
+ 'SELECT status FROM series WHERE change_id = ?', (change_id,)
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == 'waiting'
@@ -1216,7 +1357,9 @@ class TestTrackingDetailPanel:
"""Tests for the detail panel shown on series highlight."""
@pytest.mark.asyncio
- async def test_detail_panel_shows_on_highlight(self, tmp_path: pathlib.Path) -> None:
+ async def test_detail_panel_shows_on_highlight(
+ self, tmp_path: pathlib.Path
+ ) -> None:
_seed_db('test-detail', SAMPLE_SERIES)
app = TrackingApp('test-detail')
@@ -1224,6 +1367,7 @@ class TestTrackingDetailPanel:
await pilot.pause()
from textual.containers import Vertical
+
panel = app.query_one('#details-panel', Vertical)
# Panel should have non-zero height (auto-shown on first highlight)
assert panel.styles.height is not None
@@ -1240,11 +1384,14 @@ class TestTrackingDetailPanel:
await pilot.pause()
from textual.containers import Vertical
+
panel = app.query_one('#details-panel', Vertical)
assert panel.styles.height.value == 0 # type: ignore[union-attr]
@pytest.mark.asyncio
- async def test_detail_panel_updates_on_navigation(self, tmp_path: pathlib.Path) -> None:
+ async def test_detail_panel_updates_on_navigation(
+ self, tmp_path: pathlib.Path
+ ) -> None:
"""Navigating to a different series should update the detail panel."""
_seed_db('test-detail-nav', SAMPLE_SERIES)
@@ -1274,30 +1421,34 @@ class TestTrackingMultipleSeries:
"""App should correctly display a mix of new and reviewing series."""
identifier = 'test-mixed'
change_id_rev = 'mixed-reviewing-1'
- _create_review_branch(gitdir, change_id_rev, identifier=identifier,
- subject='Reviewing series')
- _seed_db(identifier, [
- {
- 'change_id': change_id_rev,
- 'subject': '[PATCH] reviewing series',
- 'status': 'reviewing',
- 'sent_at': '2026-03-10T12:00:00+00:00',
- 'message_id': 'rev@ex.com',
- },
- {
- 'change_id': 'mixed-new-1',
- 'subject': '[PATCH] new series',
- 'sent_at': '2026-03-10T11:00:00+00:00',
- 'message_id': 'new@ex.com',
- },
- {
- 'change_id': 'mixed-snoozed-1',
- 'subject': '[PATCH] snoozed series',
- 'status': 'snoozed',
- 'sent_at': '2026-03-10T10:00:00+00:00',
- 'message_id': 'snz@ex.com',
- },
- ])
+ _create_review_branch(
+ gitdir, change_id_rev, identifier=identifier, subject='Reviewing series'
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id_rev,
+ 'subject': '[PATCH] reviewing series',
+ 'status': 'reviewing',
+ 'sent_at': '2026-03-10T12:00:00+00:00',
+ 'message_id': 'rev@ex.com',
+ },
+ {
+ 'change_id': 'mixed-new-1',
+ 'subject': '[PATCH] new series',
+ 'sent_at': '2026-03-10T11:00:00+00:00',
+ 'message_id': 'new@ex.com',
+ },
+ {
+ 'change_id': 'mixed-snoozed-1',
+ 'subject': '[PATCH] snoozed series',
+ 'status': 'snoozed',
+ 'sent_at': '2026-03-10T10:00:00+00:00',
+ 'message_id': 'snz@ex.com',
+ },
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1318,23 +1469,27 @@ class TestTrackingMultipleSeries:
"""Navigate to a non-first series and enter review mode."""
identifier = 'test-nav-review'
change_id = 'nav-review-target'
- _create_review_branch(gitdir, change_id, identifier=identifier,
- subject='Target series')
- _seed_db(identifier, [
- {
- 'change_id': 'nav-review-first',
- 'subject': '[PATCH] first (new)',
- 'sent_at': '2026-03-10T12:00:00+00:00',
- 'message_id': 'first@ex.com',
- },
- {
- 'change_id': change_id,
- 'subject': '[PATCH] target (reviewing)',
- 'status': 'reviewing',
- 'sent_at': '2026-03-10T11:00:00+00:00',
- 'message_id': 'target@ex.com',
- },
- ])
+ _create_review_branch(
+ gitdir, change_id, identifier=identifier, subject='Target series'
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'nav-review-first',
+ 'subject': '[PATCH] first (new)',
+ 'sent_at': '2026-03-10T12:00:00+00:00',
+ 'message_id': 'first@ex.com',
+ },
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] target (reviewing)',
+ 'status': 'reviewing',
+ 'sent_at': '2026-03-10T11:00:00+00:00',
+ 'message_id': 'target@ex.com',
+ },
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1357,18 +1512,21 @@ class TestTrackingSnoozeRemembersChoice:
async def test_snooze_remembers_last_input(self, tmp_path: pathlib.Path) -> None:
"""Second snooze should pre-populate with the first snooze's input."""
identifier = 'test-snooze-memory'
- _seed_db(identifier, [
- {
- 'change_id': 'mem-1',
- 'subject': '[PATCH] first',
- 'message_id': 'mem1@ex.com',
- },
- {
- 'change_id': 'mem-2',
- 'subject': '[PATCH] second',
- 'message_id': 'mem2@ex.com',
- },
- ])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'mem-1',
+ 'subject': '[PATCH] first',
+ 'message_id': 'mem1@ex.com',
+ },
+ {
+ 'change_id': 'mem-2',
+ 'subject': '[PATCH] second',
+ 'message_id': 'mem2@ex.com',
+ },
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1388,12 +1546,17 @@ class TestTrackingSnoozeRemembersChoice:
# Move to the other (non-snoozed) series before snoozing it.
# The cursor may still be on the just-snoozed item, so press
# down then up to ensure we land on a non-snoozed item.
- first_cid = app._selected_series.get('change_id') if app._selected_series else None
+ first_cid = (
+ app._selected_series.get('change_id') if app._selected_series else None
+ )
if app._selected_series and app._selected_series.get('status') == 'snoozed':
await pilot.press('down')
await pilot.pause()
# If down didn't change, try up
- if app._selected_series and app._selected_series.get('change_id') == first_cid:
+ if (
+ app._selected_series
+ and app._selected_series.get('change_id') == first_cid
+ ):
await pilot.press('up')
await pilot.pause()
@@ -1414,12 +1577,11 @@ class TestTrackingSnoozeRemembersChoice:
# Lifecycle / state-machine tests
# ---------------------------------------------------------------------------
+
def _get_db_status(identifier: str, change_id: str) -> str:
"""Read the current status of a series from the tracking database."""
conn = tracking.get_db(identifier)
- cursor = conn.execute(
- 'SELECT status FROM series WHERE change_id = ?',
- (change_id,))
+ cursor = conn.execute('SELECT status FROM series WHERE change_id = ?', (change_id,))
row = cursor.fetchone()
conn.close()
assert row is not None, f'Series {change_id} not found in DB'
@@ -1456,16 +1618,22 @@ class TestSeriesLifecycle:
branch_name = f'b4/review/{change_id}'
# Seed series as 'reviewing' with a real review branch
- _create_review_branch(gitdir, change_id, identifier=identifier,
- status='reviewing')
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] lifecycle test series',
- 'sender_name': 'Lifecycle Author',
- 'sender_email': 'lifecycle@example.com',
- 'status': 'reviewing',
- 'message_id': 'lifecycle@ex.com',
- }])
+ _create_review_branch(
+ gitdir, change_id, identifier=identifier, status='reviewing'
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] lifecycle test series',
+ 'sender_name': 'Lifecycle Author',
+ 'sender_email': 'lifecycle@example.com',
+ 'status': 'reviewing',
+ 'message_id': 'lifecycle@ex.com',
+ }
+ ],
+ )
# === Phase 1: reviewing → waiting ===
app = TrackingApp(identifier)
@@ -1564,8 +1732,8 @@ class TestSeriesLifecycle:
# The real 'take' flow needs suspend + am + editor, so we seed.
conn = tracking.get_db(identifier)
conn.execute(
- 'UPDATE series SET status = ? WHERE change_id = ?',
- ('accepted', change_id))
+ 'UPDATE series SET status = ? WHERE change_id = ?', ('accepted', change_id)
+ )
conn.commit()
conn.close()
# Also update the tracking commit
@@ -1589,14 +1757,17 @@ class TestSeriesLifecycle:
await pilot.press('escape')
# === Phase 7: accepted → archived (mock _archive_branch) ===
- def _mock_archive(self_app: TrackingApp, cid: str,
- rev: Optional[int], rbranch: str,
- pw_series_id: Optional[int] = None,
- notify: bool = True) -> bool:
+ def _mock_archive(
+ self_app: TrackingApp,
+ cid: str,
+ rev: Optional[int],
+ rbranch: str,
+ pw_series_id: Optional[int] = None,
+ notify: bool = True,
+ ) -> bool:
"""Simplified archive: just update DB status."""
aconn = tracking.get_db(self_app._identifier)
- tracking.update_series_status(aconn, cid, 'archived',
- revision=rev)
+ tracking.update_series_status(aconn, cid, 'archived', revision=rev)
aconn.close()
return True
@@ -1623,11 +1794,16 @@ class TestSeriesLifecycle:
"""A new series can be snoozed without ever entering review."""
identifier = 'test-lifecycle-snooze-new'
change_id = 'direct-snooze-1'
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] snooze from new',
- 'message_id': 'ds@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] snooze from new',
+ 'message_id': 'ds@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1649,14 +1825,20 @@ class TestSeriesLifecycle:
"""A thanked series can only be archived."""
identifier = 'test-lifecycle-thanked'
change_id = 'thanked-series-1'
- _create_review_branch(gitdir, change_id, identifier=identifier,
- status='thanked')
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] thanked ready for archive',
- 'status': 'thanked',
- 'message_id': 'thanked@ex.com',
- }])
+ _create_review_branch(
+ gitdir, change_id, identifier=identifier, status='thanked'
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] thanked ready for archive',
+ 'status': 'thanked',
+ 'message_id': 'thanked@ex.com',
+ }
+ ],
+ )
# Verify action menu: only 'archive' should be available
app = TrackingApp(identifier)
@@ -1674,14 +1856,20 @@ class TestSeriesLifecycle:
"""Accepted series should show review, thank, abandon, and archive."""
identifier = 'test-lifecycle-accepted'
change_id = 'accepted-menu-1'
- _create_review_branch(gitdir, change_id, identifier=identifier,
- status='accepted')
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] accepted series menu test',
- 'status': 'accepted',
- 'message_id': 'acc@ex.com',
- }])
+ _create_review_branch(
+ gitdir, change_id, identifier=identifier, status='accepted'
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] accepted series menu test',
+ 'status': 'accepted',
+ 'message_id': 'acc@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1697,12 +1885,17 @@ class TestSeriesLifecycle:
"""A 'gone' series (branch deleted externally) should allow
review and abandon."""
identifier = 'test-lifecycle-gone'
- _seed_db(identifier, [{
- 'change_id': 'gone-1',
- 'subject': '[PATCH] gone series',
- 'status': 'gone',
- 'message_id': 'gone@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'gone-1',
+ 'subject': '[PATCH] gone series',
+ 'status': 'gone',
+ 'message_id': 'gone@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1725,8 +1918,9 @@ class TestSeriesLifecycle:
change_id = 'snooze-wait-1'
branch_name = f'b4/review/{change_id}'
# Create branch with 'snoozed' status + snoozed metadata
- _create_review_branch(gitdir, change_id, identifier=identifier,
- status='snoozed')
+ _create_review_branch(
+ gitdir, change_id, identifier=identifier, status='snoozed'
+ )
# Manually inject snoozed.previous_state into tracking commit
cover_text, trk = b4.review.load_tracking(gitdir, branch_name)
trk['series']['snoozed'] = {
@@ -1735,12 +1929,17 @@ class TestSeriesLifecycle:
}
b4.review.save_tracking_ref(gitdir, branch_name, cover_text, trk)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] waiting then snoozed',
- 'status': 'snoozed',
- 'message_id': 'sw@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] waiting then snoozed',
+ 'status': 'snoozed',
+ 'message_id': 'sw@ex.com',
+ }
+ ],
+ )
# Unsnooze should restore to 'waiting'
app = TrackingApp(identifier)
@@ -1764,14 +1963,20 @@ class TestSeriesLifecycle:
for status in ('reviewing', 'snoozed'):
identifier = f'test-lifecycle-abandon-{status}'
change_id = f'abandon-{status}'
- _create_review_branch(gitdir, change_id, identifier=identifier,
- status=status)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': f'[PATCH] abandon from {status}',
- 'status': status,
- 'message_id': f'ab-{status}@ex.com',
- }])
+ _create_review_branch(
+ gitdir, change_id, identifier=identifier, status=status
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': f'[PATCH] abandon from {status}',
+ 'status': status,
+ 'message_id': f'ab-{status}@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1789,16 +1994,18 @@ class TestSeriesLifecycle:
# Verify series removed from DB
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT change_id FROM series WHERE change_id = ?',
- (change_id,))
- assert cursor.fetchone() is None, \
+ 'SELECT change_id FROM series WHERE change_id = ?', (change_id,)
+ )
+ assert cursor.fetchone() is None, (
f'Series should be gone after abandon from {status}'
+ )
conn.close()
# Verify branch deleted
branch_name = f'b4/review/{change_id}'
- assert not b4.git_branch_exists(gitdir, branch_name), \
+ assert not b4.git_branch_exists(gitdir, branch_name), (
f'Branch should be deleted after abandon from {status}'
+ )
@patch('b4.review.tracking.get_review_target_branches', return_value=['master'])
@@ -1806,15 +2013,22 @@ class TestTargetBranch:
"""Tests for per-series target branch tracking."""
@pytest.mark.asyncio
- async def test_set_target_branch_from_new(self, _mock_branches: Any, gitdir: str) -> None:
+ async def test_set_target_branch_from_new(
+ self, _mock_branches: Any, gitdir: str
+ ) -> None:
"""Press t on a new series, type a branch, confirm — DB is updated."""
identifier = 'test-target-new'
change_id = 'target-new-1'
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] target branch test',
- 'message_id': 'target-new@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] target branch test',
+ 'message_id': 'target-new@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1840,17 +2054,24 @@ class TestTargetBranch:
assert target == 'master'
@pytest.mark.asyncio
- async def test_set_target_branch_from_reviewing(self, _mock_branches: Any, gitdir: str) -> None:
+ async def test_set_target_branch_from_reviewing(
+ self, _mock_branches: Any, gitdir: str
+ ) -> None:
"""Set target on a reviewing series — tracking commit updated too."""
identifier = 'test-target-rev'
change_id = 'target-rev-1'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] target reviewing test',
- 'status': 'reviewing',
- 'message_id': 'target-rev@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] target reviewing test',
+ 'status': 'reviewing',
+ 'message_id': 'target-rev@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
@@ -1882,11 +2103,16 @@ class TestTargetBranch:
"""Verify detail panel shows Target: row when target is set."""
identifier = 'test-target-detail'
change_id = 'target-detail-1'
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] target detail test',
- 'message_id': 'target-detail@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] target detail test',
+ 'message_id': 'target-detail@ex.com',
+ }
+ ],
+ )
# Set target in DB
conn = tracking.get_db(identifier)
tracking.update_target_branch(conn, change_id, 'sound/for-next')
@@ -1905,11 +2131,16 @@ class TestTargetBranch:
"""Ctrl+d in modal clears the target branch."""
identifier = 'test-target-clear'
change_id = 'target-clear-1'
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH] clear target test',
- 'message_id': 'target-clear@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH] clear target test',
+ 'message_id': 'target-clear@ex.com',
+ }
+ ],
+ )
# Set target first
conn = tracking.get_db(identifier)
tracking.update_target_branch(conn, change_id, 'old-branch')
@@ -1940,14 +2171,17 @@ class TestTargetBranch:
# Helpers for update-revision tests
# ---------------------------------------------------------------------------
-def _make_mock_lser(revision: int = 2, expected: int = 1,
- complete: bool = False) -> b4.LoreSeries:
+
+def _make_mock_lser(
+ revision: int = 2, expected: int = 1, complete: bool = False
+) -> b4.LoreSeries:
"""Build a minimal LoreSeries usable by _on_update_* callbacks.
Patches list contains a single MagicMock with msgid and body
attributes so the Phase 3 metadata extraction succeeds.
"""
from unittest.mock import MagicMock
+
lser = b4.LoreSeries(revision, expected)
lser.complete = complete
lser.fromname = 'Test Author'
@@ -1960,29 +2194,45 @@ def _make_mock_lser(revision: int = 2, expected: int = 1,
return lser
-def _setup_update_test(gitdir: str, identifier: str,
- change_id: str,
- current_rev: int = 1,
- target_rev: int = 2) -> str:
+def _setup_update_test(
+ gitdir: str,
+ identifier: str,
+ change_id: str,
+ current_rev: int = 1,
+ target_rev: int = 2,
+) -> str:
"""Seed a DB + review branch for update-revision tests.
Returns the review branch name.
"""
branch = _create_review_branch(
- gitdir, change_id, identifier=identifier,
- revision=current_rev, status='reviewing')
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': f'[PATCH v{current_rev}] update test',
- 'revision': current_rev,
- 'status': 'reviewing',
- 'message_id': f'v{current_rev}@ex.com',
- }])
+ gitdir,
+ change_id,
+ identifier=identifier,
+ revision=current_rev,
+ status='reviewing',
+ )
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': f'[PATCH v{current_rev}] update test',
+ 'revision': current_rev,
+ 'status': 'reviewing',
+ 'message_id': f'v{current_rev}@ex.com',
+ }
+ ],
+ )
# Register the target revision so _do_update_revision can look it up
conn = tracking.get_db(identifier)
- tracking.add_revision(conn, change_id, target_rev,
- f'v{target_rev}@ex.com',
- subject=f'[PATCH v{target_rev}] update test')
+ tracking.add_revision(
+ conn,
+ change_id,
+ target_rev,
+ f'v{target_rev}@ex.com',
+ subject=f'[PATCH v{target_rev}] update test',
+ )
conn.close()
return branch
@@ -2003,16 +2253,22 @@ class TestUpdateRevisionWorkflow:
identifier = 'test-update-nomsgid'
change_id = 'update-nomsgid-1'
_create_review_branch(gitdir, change_id, identifier=identifier)
- _seed_db(identifier, [{
- 'change_id': change_id,
- 'subject': '[PATCH v1] no msgid test',
- 'status': 'reviewing',
- 'message_id': 'v1@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': change_id,
+ 'subject': '[PATCH v1] no msgid test',
+ 'status': 'reviewing',
+ 'message_id': 'v1@ex.com',
+ }
+ ],
+ )
# Register v2 without a message-id
conn = tracking.get_db(identifier)
- tracking.add_revision(conn, change_id, 2, '',
- subject='[PATCH v2] no msgid test')
+ tracking.add_revision(
+ conn, change_id, 2, '', subject='[PATCH v2] no msgid test'
+ )
conn.close()
app = TrackingApp(identifier)
@@ -2022,9 +2278,12 @@ class TestUpdateRevisionWorkflow:
app._do_update_revision(change_id, 1, 2)
await pilot.pause()
# Should stay on the main screen, not a WorkerScreen
- assert not isinstance(app.screen,
- __import__('b4.review_tui._modals',
- fromlist=['WorkerScreen']).WorkerScreen)
+ assert not isinstance(
+ app.screen,
+ __import__(
+ 'b4.review_tui._modals', fromlist=['WorkerScreen']
+ ).WorkerScreen,
+ )
# --- Phase 2: _on_update_prepared (base selection screen) ------------
@@ -2032,32 +2291,42 @@ class TestUpdateRevisionWorkflow:
async def test_prepared_none_is_noop(self, tmp_path: pathlib.Path) -> None:
"""A None result (worker cancelled) should do nothing."""
identifier = 'test-update-none'
- _seed_db(identifier, [{
- 'change_id': 'noop-1',
- 'subject': '[PATCH] noop',
- 'message_id': 'noop@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'noop-1',
+ 'subject': '[PATCH] noop',
+ 'message_id': 'noop@ex.com',
+ }
+ ],
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
app._on_update_prepared(
- None, 'noop-1', 1, 2, 'v2@ex.com', 'subj',
- 'b4/review/noop-1')
+ None, 'noop-1', 1, 2, 'v2@ex.com', 'subj', 'b4/review/noop-1'
+ )
await pilot.pause()
# No BaseSelectionScreen should be pushed
from b4.review_tui._modals import BaseSelectionScreen
+
assert not isinstance(app.screen, BaseSelectionScreen)
@pytest.mark.asyncio
- async def test_prepared_pushes_base_selection(
- self, tmp_path: pathlib.Path) -> None:
+ async def test_prepared_pushes_base_selection(self, tmp_path: pathlib.Path) -> None:
"""Successful worker result should push BaseSelectionScreen."""
identifier = 'test-update-base'
- _seed_db(identifier, [{
- 'change_id': 'base-1',
- 'subject': '[PATCH] base select',
- 'message_id': 'base@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'base-1',
+ 'subject': '[PATCH] base select',
+ 'message_id': 'base@ex.com',
+ }
+ ],
+ )
lser = _make_mock_lser()
ambytes = b'fake mbox'
result = (lser, ambytes, 'abc123456789', 'Guessed base: foo', 1)
@@ -2066,39 +2335,58 @@ class TestUpdateRevisionWorkflow:
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
app._on_update_prepared(
- result, 'base-1', 1, 2, 'v2@ex.com',
- '[PATCH v2] base select', 'b4/review/base-1')
+ result,
+ 'base-1',
+ 1,
+ 2,
+ 'v2@ex.com',
+ '[PATCH v2] base select',
+ 'b4/review/base-1',
+ )
await pilot.pause()
from b4.review_tui._modals import BaseSelectionScreen
+
assert isinstance(app.screen, BaseSelectionScreen)
# --- Phase 3: _on_update_base_selected (apply + swap) ----------------
@pytest.mark.asyncio
- async def test_base_selected_none_cancels(
- self, tmp_path: pathlib.Path) -> None:
+ async def test_base_selected_none_cancels(self, tmp_path: pathlib.Path) -> None:
"""Passing None as base_sha should cancel the update."""
identifier = 'test-update-cancel'
- _seed_db(identifier, [{
- 'change_id': 'cancel-1',
- 'subject': '[PATCH] cancel',
- 'message_id': 'cancel@ex.com',
- }])
+ _seed_db(
+ identifier,
+ [
+ {
+ 'change_id': 'cancel-1',
+ 'subject': '[PATCH] cancel',
+ 'message_id': 'cancel@ex.com',
+ }
+ ],
+ )
lser = _make_mock_lser()
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
app._on_update_base_selected(
- None, lser, b'mbox', 1, 'cancel-1', 1, 2,
- 'v2@ex.com', 'subj', 'b4/review/cancel-1')
+ None,
+ lser,
+ b'mbox',
+ 1,
+ 'cancel-1',
+ 1,
+ 2,
+ 'v2@ex.com',
+ 'subj',
+ 'b4/review/cancel-1',
+ )
await pilot.pause()
# App should still be running — not exited
assert app.is_running
@pytest.mark.asyncio
- async def test_apply_failure_preserves_old_branch(
- self, gitdir: str) -> None:
+ async def test_apply_failure_preserves_old_branch(self, gitdir: str) -> None:
"""When git-am fails the old review branch must remain intact."""
identifier = 'test-update-fail'
change_id = 'update-fail-1'
@@ -2106,8 +2394,7 @@ class TestUpdateRevisionWorkflow:
upgrade_branch = f'b4/review/_tmp-{change_id}-v2-upgrade'
# Snapshot old branch HEAD before the attempt
- ecode, old_head = b4.git_run_command(
- gitdir, ['rev-parse', review_branch])
+ ecode, old_head = b4.git_run_command(gitdir, ['rev-parse', review_branch])
assert ecode == 0
old_head = old_head.strip()
@@ -2116,21 +2403,34 @@ class TestUpdateRevisionWorkflow:
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
- with patch.object(app, 'suspend', return_value=__import__(
- 'contextlib').nullcontext()), \
- patch.object(app, 'exit'), \
- patch('b4.review_tui._tracking_app._wait_for_enter'), \
- patch('b4.git_fetch_am_into_repo',
- side_effect=RuntimeError('apply failed')):
+ with (
+ patch.object(
+ app, 'suspend', return_value=__import__('contextlib').nullcontext()
+ ),
+ patch.object(app, 'exit'),
+ patch('b4.review_tui._tracking_app._wait_for_enter'),
+ patch(
+ 'b4.git_fetch_am_into_repo',
+ side_effect=RuntimeError('apply failed'),
+ ),
+ ):
app._on_update_base_selected(
- 'HEAD', lser, b'mbox', 1, change_id, 1, 2,
- 'v2@ex.com', 'subj', review_branch)
+ 'HEAD',
+ lser,
+ b'mbox',
+ 1,
+ change_id,
+ 1,
+ 2,
+ 'v2@ex.com',
+ 'subj',
+ review_branch,
+ )
await pilot.pause()
# Old review branch must still exist with unchanged HEAD
assert b4.git_branch_exists(gitdir, review_branch)
- ecode, cur_head = b4.git_run_command(
- gitdir, ['rev-parse', review_branch])
+ ecode, cur_head = b4.git_run_command(gitdir, ['rev-parse', review_branch])
assert ecode == 0
assert cur_head.strip() == old_head
@@ -2140,24 +2440,22 @@ class TestUpdateRevisionWorkflow:
# DB should still show original revision
conn = tracking.get_db(identifier)
cursor = conn.execute(
- 'SELECT revision, status FROM series WHERE change_id = ?',
- (change_id,))
+ 'SELECT revision, status FROM series WHERE change_id = ?', (change_id,)
+ )
row = cursor.fetchone()
conn.close()
assert row[0] == 1
assert row[1] == 'reviewing'
@pytest.mark.asyncio
- async def test_conflict_abort_preserves_old_branch(
- self, gitdir: str) -> None:
+ async def test_conflict_abort_preserves_old_branch(self, gitdir: str) -> None:
"""When user aborts conflict resolution the old branch stays."""
identifier = 'test-update-abort'
change_id = 'update-abort-1'
review_branch = _setup_update_test(gitdir, identifier, change_id)
upgrade_branch = f'b4/review/_tmp-{change_id}-v2-upgrade'
- ecode, old_head = b4.git_run_command(
- gitdir, ['rev-parse', review_branch])
+ ecode, old_head = b4.git_run_command(gitdir, ['rev-parse', review_branch])
assert ecode == 0
old_head = old_head.strip()
@@ -2167,23 +2465,35 @@ class TestUpdateRevisionWorkflow:
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
- with patch.object(app, 'suspend', return_value=__import__(
- 'contextlib').nullcontext()), \
- patch.object(app, 'exit'), \
- patch('b4.review_tui._tracking_app._wait_for_enter'), \
- patch('b4.git_fetch_am_into_repo',
- side_effect=conflict), \
- patch('b4.review_tui._tracking_app._resolve_worktree_am_conflict',
- return_value=False):
+ with (
+ patch.object(
+ app, 'suspend', return_value=__import__('contextlib').nullcontext()
+ ),
+ patch.object(app, 'exit'),
+ patch('b4.review_tui._tracking_app._wait_for_enter'),
+ patch('b4.git_fetch_am_into_repo', side_effect=conflict),
+ patch(
+ 'b4.review_tui._tracking_app._resolve_worktree_am_conflict',
+ return_value=False,
+ ),
+ ):
app._on_update_base_selected(
- 'HEAD', lser, b'mbox', 1, change_id, 1, 2,
- 'v2@ex.com', 'subj', review_branch)
+ 'HEAD',
+ lser,
+ b'mbox',
+ 1,
+ change_id,
+ 1,
+ 2,
+ 'v2@ex.com',
+ 'subj',
+ review_branch,
+ )
await pilot.pause()
# Old review branch must be untouched
assert b4.git_branch_exists(gitdir, review_branch)
- ecode, cur_head = b4.git_run_command(
- gitdir, ['rev-parse', review_branch])
+ ecode, cur_head = b4.git_run_command(gitdir, ['rev-parse', review_branch])
assert ecode == 0
assert cur_head.strip() == old_head
@@ -2191,8 +2501,7 @@ class TestUpdateRevisionWorkflow:
assert not b4.git_branch_exists(gitdir, upgrade_branch)
@pytest.mark.asyncio
- async def test_successful_upgrade_renames_branch(
- self, gitdir: str) -> None:
+ async def test_successful_upgrade_renames_branch(self, gitdir: str) -> None:
"""On success the upgrade branch replaces the old review branch."""
identifier = 'test-update-ok'
change_id = 'update-ok-1'
@@ -2205,56 +2514,80 @@ class TestUpdateRevisionWorkflow:
assert ecode == 0
base = base.strip()
- def _fake_create(topdir: str, branch: str, base_commit: str,
- lser_arg: b4.LoreSeries, linkurl: str,
- linkmask: str, num_prereqs: int = 0,
- identifier: Optional[str] = None,
- status: str = 'reviewing',
- **kwargs: Any) -> None:
+ def _fake_create(
+ topdir: str,
+ branch: str,
+ base_commit: str,
+ lser_arg: b4.LoreSeries,
+ linkurl: str,
+ linkmask: str,
+ num_prereqs: int = 0,
+ identifier: Optional[str] = None,
+ status: str = 'reviewing',
+ **kwargs: Any,
+ ) -> None:
"""Simulate create_review_branch by making a real branch."""
branch_suffix = branch.removeprefix('b4/review/')
- _create_review_branch(topdir, branch_suffix,
- identifier=identifier or 'test',
- revision=2, status='reviewing')
-
- def _mock_archive(self_app: TrackingApp, cid: str,
- rev: Optional[int], rbranch: str,
- pw_series_id: Optional[int] = None,
- notify: bool = True) -> bool:
+ _create_review_branch(
+ topdir,
+ branch_suffix,
+ identifier=identifier or 'test',
+ revision=2,
+ status='reviewing',
+ )
+
+ def _mock_archive(
+ self_app: TrackingApp,
+ cid: str,
+ rev: Optional[int],
+ rbranch: str,
+ pw_series_id: Optional[int] = None,
+ notify: bool = True,
+ ) -> bool:
"""Delete branch + mark archived in DB."""
b4.git_run_command(gitdir, ['branch', '-D', rbranch])
aconn = tracking.get_db(self_app._identifier)
- tracking.update_series_status(aconn, cid, 'archived',
- revision=rev)
+ tracking.update_series_status(aconn, cid, 'archived', revision=rev)
aconn.close()
return True
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
- with patch.object(app, 'suspend', return_value=__import__(
- 'contextlib').nullcontext()), \
- patch('b4.review_tui._tracking_app._wait_for_enter'), \
- patch('b4.git_fetch_am_into_repo'), \
- patch('b4.review.create_review_branch',
- side_effect=_fake_create), \
- patch('b4.review.get_review_branch_patch_ids',
- return_value=[]), \
- patch('b4.review.load_tracking',
- return_value=('', {'series': {}, 'patches': []})), \
- patch('b4.review.reanchor_patch_comments'), \
- patch('b4.review.save_tracking_ref'), \
- patch.object(TrackingApp, '_archive_branch',
- _mock_archive):
+ with (
+ patch.object(
+ app, 'suspend', return_value=__import__('contextlib').nullcontext()
+ ),
+ patch('b4.review_tui._tracking_app._wait_for_enter'),
+ patch('b4.git_fetch_am_into_repo'),
+ patch('b4.review.create_review_branch', side_effect=_fake_create),
+ patch('b4.review.get_review_branch_patch_ids', return_value=[]),
+ patch(
+ 'b4.review.load_tracking',
+ return_value=('', {'series': {}, 'patches': []}),
+ ),
+ patch('b4.review.reanchor_patch_comments'),
+ patch('b4.review.save_tracking_ref'),
+ patch.object(TrackingApp, '_archive_branch', _mock_archive),
+ ):
app._on_update_base_selected(
- base, lser, b'mbox', 1, change_id, 1, 2,
- 'v2@ex.com', '[PATCH v2] update test',
- review_branch)
+ base,
+ lser,
+ b'mbox',
+ 1,
+ change_id,
+ 1,
+ 2,
+ 'v2@ex.com',
+ '[PATCH v2] update test',
+ review_branch,
+ )
await pilot.pause()
# Upgrade branch should be gone (was renamed)
assert not b4.git_branch_exists(
- gitdir, f'b4/review/_tmp-{change_id}-v2-upgrade')
+ gitdir, f'b4/review/_tmp-{change_id}-v2-upgrade'
+ )
# Upgrade branch should have been renamed to review branch
assert b4.git_branch_exists(gitdir, review_branch)
@@ -2263,7 +2596,8 @@ class TestUpdateRevisionWorkflow:
cursor = conn.execute(
'SELECT revision, status FROM series'
' WHERE change_id = ? AND revision = 2',
- (change_id,))
+ (change_id,),
+ )
row = cursor.fetchone()
conn.close()
assert row is not None
@@ -2273,8 +2607,7 @@ class TestUpdateRevisionWorkflow:
assert app.is_running
@pytest.mark.asyncio
- async def test_archive_failure_leaves_both_branches(
- self, gitdir: str) -> None:
+ async def test_archive_failure_leaves_both_branches(self, gitdir: str) -> None:
"""If archiving fails, both branches are left for manual recovery."""
identifier = 'test-update-archfail'
change_id = 'update-archfail-1'
@@ -2283,34 +2616,48 @@ class TestUpdateRevisionWorkflow:
lser = _make_mock_lser()
- def _fake_create(topdir: str, branch: str, *args: Any,
- **kwargs: Any) -> None:
+ def _fake_create(topdir: str, branch: str, *args: Any, **kwargs: Any) -> None:
branch_suffix = branch.removeprefix('b4/review/')
- _create_review_branch(topdir, branch_suffix,
- identifier=identifier,
- revision=2, status='reviewing')
+ _create_review_branch(
+ topdir,
+ branch_suffix,
+ identifier=identifier,
+ revision=2,
+ status='reviewing',
+ )
app = TrackingApp(identifier)
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
- with patch.object(app, 'suspend', return_value=__import__(
- 'contextlib').nullcontext()), \
- patch.object(app, 'exit'), \
- patch('b4.review_tui._tracking_app._wait_for_enter'), \
- patch('b4.git_fetch_am_into_repo'), \
- patch('b4.review.create_review_branch',
- side_effect=_fake_create), \
- patch('b4.review.get_review_branch_patch_ids',
- return_value=[]), \
- patch('b4.review.load_tracking',
- return_value=('', {'series': {}, 'patches': []})), \
- patch('b4.review.reanchor_patch_comments'), \
- patch('b4.review.save_tracking_ref'), \
- patch.object(TrackingApp, '_archive_branch',
- return_value=False):
+ with (
+ patch.object(
+ app, 'suspend', return_value=__import__('contextlib').nullcontext()
+ ),
+ patch.object(app, 'exit'),
+ patch('b4.review_tui._tracking_app._wait_for_enter'),
+ patch('b4.git_fetch_am_into_repo'),
+ patch('b4.review.create_review_branch', side_effect=_fake_create),
+ patch('b4.review.get_review_branch_patch_ids', return_value=[]),
+ patch(
+ 'b4.review.load_tracking',
+ return_value=('', {'series': {}, 'patches': []}),
+ ),
+ patch('b4.review.reanchor_patch_comments'),
+ patch('b4.review.save_tracking_ref'),
+ patch.object(TrackingApp, '_archive_branch', return_value=False),
+ ):
app._on_update_base_selected(
- 'HEAD', lser, b'mbox', 1, change_id, 1, 2,
- 'v2@ex.com', 'subj', review_branch)
+ 'HEAD',
+ lser,
+ b'mbox',
+ 1,
+ change_id,
+ 1,
+ 2,
+ 'v2@ex.com',
+ 'subj',
+ review_branch,
+ )
await pilot.pause()
# Both branches should exist — user can recover manually
@@ -2334,7 +2681,9 @@ class TestLoadSeriesCaching:
assert app._cached_revision_counts is not None
@pytest.mark.asyncio
- async def test_caches_survive_db_poll_no_change(self, tmp_path: pathlib.Path) -> None:
+ async def test_caches_survive_db_poll_no_change(
+ self, tmp_path: pathlib.Path
+ ) -> None:
"""Caches should persist when _check_db_changed finds no change."""
_seed_db('cache-nochg', SAMPLE_SERIES)
@@ -2348,7 +2697,9 @@ class TestLoadSeriesCaching:
assert id(app._cached_branch_tips) == tips_id
@pytest.mark.asyncio
- async def test_full_invalidation_clears_all_caches(self, tmp_path: pathlib.Path) -> None:
+ async def test_full_invalidation_clears_all_caches(
+ self, tmp_path: pathlib.Path
+ ) -> None:
"""_invalidate_caches() without change_id clears everything."""
_seed_db('cache-full-inv', SAMPLE_SERIES)
@@ -2364,7 +2715,8 @@ class TestLoadSeriesCaching:
@pytest.mark.asyncio
async def test_selective_invalidation_keeps_other_caches(
- self, tmp_path: pathlib.Path) -> None:
+ self, tmp_path: pathlib.Path
+ ) -> None:
"""_invalidate_caches(change_id) only evicts that ART entry."""
_seed_db('cache-sel-inv', SAMPLE_SERIES)
@@ -2398,8 +2750,11 @@ class TestLoadSeriesCaching:
async with app.run_test(size=(120, 30)) as pilot:
await pilot.pause()
# Find the charlie series and check its stashed revisions
- charlie = [s for s in app._all_series
- if s.get('change_id') == 'test-change-charlie']
+ charlie = [
+ s
+ for s in app._all_series
+ if s.get('change_id') == 'test-change-charlie'
+ ]
assert len(charlie) == 1
revs = charlie[0].get('_revisions', [])
assert len(revs) == 2
@@ -2408,16 +2763,19 @@ class TestLoadSeriesCaching:
@pytest.mark.asyncio
async def test_snoozed_until_in_series(self, tmp_path: pathlib.Path) -> None:
"""_load_series should include snoozed_until from the DB."""
- series = [{
- 'change_id': 'test-snooze-detail',
- 'subject': '[PATCH] snooze test',
- 'sender_name': 'Tester',
- 'status': 'snoozed',
- }]
+ series = [
+ {
+ 'change_id': 'test-snooze-detail',
+ 'subject': '[PATCH] snooze test',
+ 'sender_name': 'Tester',
+ 'status': 'snoozed',
+ }
+ ]
_seed_db('cache-snooze', series)
conn = tracking.get_db('cache-snooze')
- tracking.snooze_series(conn, 'test-snooze-detail',
- '2026-06-01T00:00:00', revision=1)
+ tracking.snooze_series(
+ conn, 'test-snooze-detail', '2026-06-01T00:00:00', revision=1
+ )
conn.close()
app = TrackingApp('cache-snooze')
--
2.53.0
next prev parent reply other threads:[~2026-04-19 16:00 UTC|newest]
Thread overview: 14+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-19 15:59 [PATCH b4 v2 00/11] Enable stricter local checks Tamir Duberstein
2026-04-19 15:59 ` [PATCH b4 v2 01/11] Add CI script Tamir Duberstein
2026-04-19 15:59 ` [PATCH b4 v2 02/11] Add ruff checks to CI Tamir Duberstein
2026-04-19 15:59 ` [PATCH b4 v2 03/11] Import dependencies unconditionally Tamir Duberstein
2026-04-19 15:59 ` Tamir Duberstein [this message]
2026-04-19 18:06 ` [PATCH b4 v2 04/11] Add ruff format check to CI Tamir Duberstein
2026-04-19 16:00 ` [PATCH b4 v2 05/11] Fix tests under uv with complex git config Tamir Duberstein
2026-04-19 16:00 ` [PATCH b4 v2 06/11] Fix typings in misc/ Tamir Duberstein
2026-04-19 16:00 ` [PATCH b4 v2 07/11] Enable mypy unreachable warnings Tamir Duberstein
2026-04-19 16:00 ` [PATCH b4 v2 08/11] Enable and fix pyright diagnostics Tamir Duberstein
2026-04-19 16:00 ` [PATCH b4 v2 09/11] Avoid duplicate map lookups Tamir Duberstein
2026-04-19 16:00 ` [PATCH b4 v2 10/11] Add ty and configuration Tamir Duberstein
2026-04-19 16:00 ` [PATCH b4 v2 11/11] Enable pyright strict mode Tamir Duberstein
2026-04-23 2:48 ` [PATCH b4 v2 00/11] Enable stricter local checks Konstantin Ryabitsev
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260419-ruff-check-v2-4-089dfb264501@kernel.org \
--to=tamird@kernel.org \
--cc="str = 'author"@example.com' \
--cc="str = 'patch1"@example.com' \
--cc="str = 'reply"@example.com' \
--cc="str = 'reviewer"@example.com' \
--cc=konstantin@linuxfoundation.org \
--cc=tools@kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox