Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
From: Mark Brown <broonie@kernel.org>
To: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
Cc: tools@kernel.org, Mark Brown <broonie@kernel.org>
Subject: [PATCH b4] review: Send replies for skipped patches
Date: Wed, 11 Nov 2026 19:37:28 +0000	[thread overview]
Message-ID: <20261111-reply-skipped-v1-1-2bdb48ab8e3c@kernel.org> (raw)

Currently when a patch is skipped this includes for email mode, even if
a reply has been written.  This seems unhelpful, if the user has written
something it seems reasonable they would want to send it and if they
want to not send a reply they can always delete it.  Skipping also
affects patch application and one reason a user might reply to a patch
is to explain why they are not taking it.

Change the behaviour so that replies are also sent for skipped patches.

Signed-off-by: Mark Brown <broonie@kernel.org>
---
 docs/maintainer/review.rst       |  5 +--
 src/b4/review/_review.py         | 12 +++----
 src/b4/review_tui/_review_app.py | 22 +++---------
 src/tests/test_review.py         | 20 +++++------
 src/tests/test_tui_review.py     | 77 +++++++++++++++++++++++++++++++++++++++-
 5 files changed, 96 insertions(+), 40 deletions(-)

diff --git a/docs/maintainer/review.rst b/docs/maintainer/review.rst
index 5a3e9d9..78a1108 100644
--- a/docs/maintainer/review.rst
+++ b/docs/maintainer/review.rst
@@ -760,7 +760,7 @@ Unchanged   ``≡``   —       Patch identical to prior revision (auto, on upgr
 External    ``±``   —       External reviewers have inline comments (auto)
 Draft       ``✎``   —       You have comments, a reply, or a nack trailer (auto)
 Done        ``✓``   ``d``   Review complete, include in outgoing emails (also auto)
-Skip        ``✕``   ``x``   Intentionally skipped, exclude from outgoing emails
+Skip        ``✕``   ``x``   Intentionally skipped; any replies are still sent
 ==========  ======  ======  =======================================================
 
 .. |sp| unicode:: U+0020
@@ -788,7 +788,8 @@ bold, skipped and unchanged patches appear dimmed. Press ``H`` to hide
 skipped patches from the list entirely — useful for large series where
 many patches are irrelevant to your review.
 
-Skipped patches are automatically excluded when sending review emails.
+Any unsent replies, comments, or trailers on skipped patches are still
+included when previewing and sending review emails.
 When taking patches via cherry-pick, skipped patches are pre-deselected
 in the patch selection dialog.
 
diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index b69a7f0..3e1027e 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -3914,8 +3914,8 @@ def collect_review_emails(
 ) -> List[email.message.EmailMessage]:
     """Collect review emails to send for the given series.
 
-    Only sends the maintainer's own reviews, not agent or other
-    reviewers' data.
+    Only sends the maintainer's own unsent reviews, not agent or other
+    reviewers' data. Skipped patches can still have replies to send.
     """
     usercfg = b4.get_user_config()
     my_email = str(usercfg.get('email', 'unknown@example.com'))
@@ -3923,11 +3923,7 @@ 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
-    ):
+    if cover_review 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)
@@ -3935,7 +3931,7 @@ def collect_review_emails(
     # Per-patch reviews (maintainer only)
     for idx, patch_meta in enumerate(patches):
         patch_review = patch_meta.get('reviews', {}).get(my_email, {})
-        if not patch_review or patch_review.get('patch-state') == 'skip':
+        if not patch_review:
             continue
         if patch_review.get('sent-revision') is not None:
             continue
diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py
index 1e70bc6..2eec9b5 100644
--- a/src/b4/review_tui/_review_app.py
+++ b/src/b4/review_tui/_review_app.py
@@ -857,22 +857,6 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]):
                 else None
             )
 
-        target = self._series if display_idx == 0 else patch_meta
-        if target and b4.review._get_patch_state(target, self._usercfg) == 'skip':
-            total = len(self._commit_shas)
-            label = 'cover' if display_idx == 0 else f'{display_idx}/{total}'
-            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]'
-                )
-            else:
-                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')
         ):
@@ -1805,15 +1789,17 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]):
             try:
                 self._reply_sent = True
                 self._tracking['series']['status'] = 'replied'
-                # Stamp sent-revision on every non-skip review so that
+                # Stamp sent-revision on each review whose email was sent so that
                 # if this series is later upgraded to a newer revision,
                 # the upgrade step can detect which reviews were already
                 # sent and auto-skip the unchanged patches.
                 my_email = str(self._usercfg.get('email', 'unknown@example.com'))
                 current_rev = int(self._series.get('revision', 1))
+                replied_to = {msg['In-Reply-To'] for msg in msgs}
                 for target in [self._series] + list(self._patches):
                     review = target.get('reviews', {}).get(my_email, {})
-                    if review and review.get('patch-state') != 'skip':
+                    msgid = target.get('header-info', {}).get('msgid', '')
+                    if review and f'<{msgid}>' in replied_to:
                         review['sent-revision'] = current_rev
                 if not self._save_tracking():
                     # Silence here means the next session offers these reviews
diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index e40dd35..0f22c00 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -4212,10 +4212,11 @@ class TestCollectReviewEmails:
             pytest.param(False, id='normal-patch-review'),
         ],
     )
+    @pytest.mark.parametrize('patch_state', ['', 'done', 'skip'])
     def test_sends_unsent_review(
-        self, _cfg: mock.Mock, _build: mock.Mock, on_cover: bool
+        self, _cfg: mock.Mock, _build: mock.Mock, on_cover: bool, patch_state: str
     ) -> None:
-        msgs = self._collect(on_cover, {})
+        msgs = self._collect(on_cover, {'patch-state': patch_state})
         assert len(msgs) == 1
 
     @pytest.mark.parametrize(
@@ -4225,10 +4226,8 @@ class TestCollectReviewEmails:
             pytest.param(True, {'sent-revision': 1}, id='cover-with-sent-revision'),
             pytest.param(False, {'sent-revision': 1}, id='patch-with-sent-revision'),
             # The upgrade step sets patch-state=skip AND skip-reason on
-            # unchanged patches whose review was already sent (combo A+B
-            # fix).  Both the skip filter and the sent-revision filter
-            # independently prevent re-sending; this row exercises the
-            # skip-state path.
+            # unchanged patches whose review was already sent. The
+            # sent-revision filter must still prevent re-sending them.
             pytest.param(
                 False,
                 {
@@ -4238,15 +4237,14 @@ class TestCollectReviewEmails:
                 },
                 id='patch-auto-skipped-after-upgrade',
             ),
-            # Explicit skip state (manually set, no sent-revision) is honoured.
             pytest.param(
-                False,
-                {'patch-state': 'skip'},
-                id='skip-state-without-sent-revision',
+                True,
+                {'sent-revision': 1, 'patch-state': 'skip'},
+                id='skipped-cover-with-sent-revision',
             ),
         ],
     )
-    def test_skips_already_sent_or_skipped(
+    def test_skips_already_sent(
         self,
         _cfg: mock.Mock,
         _build: mock.Mock,
diff --git a/src/tests/test_tui_review.py b/src/tests/test_tui_review.py
index e164bf6..6ccbfd9 100644
--- a/src/tests/test_tui_review.py
+++ b/src/tests/test_tui_review.py
@@ -21,7 +21,7 @@ import pytest
 
 pytest.importorskip('textual')
 
-from textual.widgets import ListView
+from textual.widgets import ListView, RichLog
 
 import b4
 import b4.review
@@ -690,6 +690,81 @@ class TestBranchRestore:
         assert app.branch_checked_out is False
 
 
+class TestSkippedReplies:
+    """Skipped patches can still have replies to preview and send."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.parametrize('on_cover', [False, True], ids=['patch', 'cover'])
+    @pytest.mark.parametrize('dryrun', [False, True], ids=['send', 'dryrun'])
+    async def test_skipped_reply_is_previewed_and_sent(
+        self, gitdir: str, on_cover: bool, dryrun: bool
+    ) -> None:
+        import contextlib
+
+        branch, _shas = _create_review_branch_with_patches(
+            gitdir,
+            'skipped-reply',
+            ['patch 1', 'patch 2'],
+            series_extra={'header-info': {'msgid': 'cover@example.com'}},
+        )
+        session = _build_session(gitdir, branch)
+        session['email_dryrun'] = dryrun
+        my_email = str(session['usercfg']['email'])
+        target = session['series'] if on_cover else session['patches'][0]
+        reply = 'Please fix this before I can take it.'
+        target['reviews'] = {my_email: {'patch-state': 'skip', 'reply': reply}}
+        empty_target = session['patches'][1]
+        empty_target['reviews'] = {my_email: {'patch-state': 'skip'}}
+
+        app = ReviewApp(session)
+        async with app.run_test(size=(120, 30)) as pilot:
+            await pilot.pause()
+            app._show_content(0 if on_cover else 1)
+            await pilot.press('e')
+            await pilot.pause()
+            viewer = app.query_one('#diff-viewer', RichLog)
+            assert reply in '\n'.join(line.text for line in viewer.lines)
+
+            def _confirm(screen: Any, callback: Any = None) -> None:
+                assert callback is not None
+                callback(True)
+
+            with (
+                mock.patch.object(app, 'suspend', contextlib.nullcontext),
+                mock.patch.object(app, 'push_screen', _confirm),
+                mock.patch('b4.get_smtp', return_value=(None, 'sender@example.com')),
+                mock.patch('b4.send_mail', return_value=0 if dryrun else 1) as send,
+                mock.patch('b4.review_tui._review_app.mark_outgoing_seen'),
+                mock.patch.object(app, '_mark_patches_answered'),
+            ):
+                app.action_send()
+                send.assert_called_once()
+                msgs = send.call_args.args[1]
+                assert len(msgs) == 1
+                assert msgs[0]['In-Reply-To'] == f'<{target["header-info"]["msgid"]}>'
+                assert reply in b4.LoreMessage.get_payload(msgs[0])[0]
+
+                _cover, saved = b4.review.load_tracking(gitdir, branch)
+                saved_target = saved['series'] if on_cover else saved['patches'][0]
+                saved_review = saved_target['reviews'][my_email]
+                assert saved_review['patch-state'] == 'skip'
+                assert 'sent-revision' not in saved['patches'][1]['reviews'][my_email]
+                if dryrun:
+                    assert 'sent-revision' not in saved_review
+                    unsent = b4.review.collect_review_emails(
+                        app._series,
+                        app._patches,
+                        app._cover_text,
+                        app._topdir,
+                        app._commit_shas,
+                    )
+                    assert len(unsent) == 1
+                else:
+                    assert saved_review['sent-revision'] == saved['series']['revision']
+                    app.action_send()
+                    send.assert_called_once()
+
+
 class TestSendBookkeeping:
     """Everything after the SMTP handoff is bookkeeping, and bookkeeping must
     never be able to claim the send failed."""

---
base-commit: ce4c1158edb292caca0ce4a6087e5edecc1b0371
change-id: 20261111-reply-skipped-325b3debc6f4

Best regards,
--  
Mark Brown <broonie@kernel.org>


                 reply	other threads:[~2026-11-11 19:43 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

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=20261111-reply-skipped-v1-1-2bdb48ab8e3c@kernel.org \
    --to=broonie@kernel.org \
    --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