Linux maintainer tooling and workflows
 help / color / mirror / Atom feed
From: Brian Masney <masneyb@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
	 Jerome Brunet <jbrunet@baylibre.com>,
	Brian Masney <masneyb@kernel.org>
Subject: [PATCH b4 1/4] review-tui: show why a Patchwork update failed
Date: Wed, 14 Oct 2026 20:03:38 -0400	[thread overview]
Message-ID: <20261014-pw-delegate-v1-1-a393ae92152a@kernel.org> (raw)
In-Reply-To: <20261014-pw-delegate-v1-0-a393ae92152a@kernel.org>

When Patchwork rejects a state change, the Patchwork browser only says
"N updated, M failed", and the server's reason is thrown away. That
leaves the user guessing whether it was a permission problem, a bad
value, or the server being down. Keep the reason from the first patch
that failed and add it to the notice.

Assisted-by: LLM
Signed-off-by: Brian Masney <masneyb@kernel.org>
---
 src/b4/review_tui/_modals.py | 36 +++++++++++++++++++++++++++--
 src/b4/review_tui/_pw_app.py |  5 +++-
 src/tests/test_tui_pw.py     | 55 ++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 93 insertions(+), 3 deletions(-)

diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 8a0f46444a01..d1b878870b92 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -2716,6 +2716,29 @@ def SetStateConfirmScreen(
 PW_PATCH_TIMEOUT = 30
 
 
+def _pw_error_text(ex: Exception) -> str:
+    """Describe a failed Patchwork request, preferring the server's reason.
+
+    Patchwork answers a rejected PATCH with a JSON body mapping each field
+    to its errors, e.g. ``{"delegate": ["User 'x' is not a maintainer..."]}``.
+    """
+    rsp = getattr(ex, 'response', None)
+    if rsp is None:
+        return str(ex)
+    try:
+        body = rsp.json()
+    except ValueError:
+        body = None
+    if isinstance(body, dict) and body:
+        parts = []
+        for field, errs in body.items():
+            if isinstance(errs, list):
+                errs = ' '.join(str(e) for e in errs)
+            parts.append(f'{field}: {errs}')
+        return '; '.join(parts)
+    return f'{rsp.status_code} {rsp.reason}'
+
+
 class ApplyStateResult(NamedTuple):
     """Outcome of an ApplyStateModal run."""
 
@@ -2725,6 +2748,8 @@ class ApplyStateResult(NamedTuple):
     new_state: str
     # True when the user stopped the run with Esc before it finished
     cancelled: bool
+    # Why the first failed patch failed, as the server put it ('' if none)
+    error: str = ''
 
 
 class ApplyStateModal(ModalScreen[ApplyStateResult]):
@@ -2785,6 +2810,7 @@ class ApplyStateModal(ModalScreen[ApplyStateResult]):
         self._series_name = series_name
         self._done_ids: Set[int] = set()
         self._fail = 0
+        self._error = ''
         self._cancelled = False
 
     def compose(self) -> ComposeResult:
@@ -2812,7 +2838,11 @@ class ApplyStateModal(ModalScreen[ApplyStateResult]):
 
     def _result(self) -> ApplyStateResult:
         return ApplyStateResult(
-            set(self._done_ids), self._fail, self._new_state, self._cancelled
+            set(self._done_ids),
+            self._fail,
+            self._new_state,
+            self._cancelled,
+            self._error,
         )
 
     def _apply_states(self) -> ApplyStateResult:
@@ -2841,8 +2871,10 @@ class ApplyStateModal(ModalScreen[ApplyStateResult]):
                     )
                     rsp.raise_for_status()
                     self._done_ids.add(patch_id)
-                except Exception:
+                except Exception as ex:
                     self._fail += 1
+                    if not self._error:
+                        self._error = _pw_error_text(ex)
 
                 # Update progress from worker thread
                 self.app.call_from_thread(self._update_progress, i + 1)
diff --git a/src/b4/review_tui/_pw_app.py b/src/b4/review_tui/_pw_app.py
index 7e49b399d885..6e99488d7e35 100644
--- a/src/b4/review_tui/_pw_app.py
+++ b/src/b4/review_tui/_pw_app.py
@@ -821,7 +821,10 @@ class PwApp(LoreNodeShutdownMixin, App[None]):
                 severity='warning',
             )
         elif result.fail:
-            self.notify(f'{ok} updated, {result.fail} failed', severity='warning')
+            msg = f'{ok} updated, {result.fail} failed'
+            if result.error:
+                msg += f': {escape(result.error)}'
+            self.notify(msg, severity='warning')
         elif len(targets) == 1:
             self.notify(f'{ok} patch(es) set to {new_state}', severity='information')
         else:
diff --git a/src/tests/test_tui_pw.py b/src/tests/test_tui_pw.py
index 466b36517895..c7af958e895f 100644
--- a/src/tests/test_tui_pw.py
+++ b/src/tests/test_tui_pw.py
@@ -16,6 +16,7 @@ from typing import Any, Dict, List, Optional
 from unittest import mock
 
 import pytest
+import requests
 
 pytest.importorskip('textual')
 
@@ -875,3 +876,57 @@ class TestPwSetStateNullName:
             assert modal._series_name == '(no subject)'
             label = modal.query_one('#apply-series', Label)
             assert static_text(label) == '(no subject)'
+
+
+class TestPwApplyErrorText:
+    """A rejected PATCH reports the server's reason, not just a count."""
+
+    @pytest.mark.asyncio
+    async def test_failure_reason_in_notice(
+        self, monkeypatch: pytest.MonkeyPatch
+    ) -> None:
+        class _RejectingSession(_FakePwSession):
+            def patch(
+                self,
+                url: str,
+                data: Any = None,
+                stream: bool = False,
+                timeout: Optional[float] = None,
+            ) -> _FakeResp:
+                super().patch(url, data=data, stream=stream, timeout=timeout)
+                rsp = requests.Response()
+                rsp.status_code = 400
+                rsp.reason = 'Bad Request'
+                rsp._content = (
+                    b'{"state": ["Object with slug=reviewing does not exist."]}'
+                )
+                raise requests.HTTPError('400 Client Error', response=rsp)
+
+        fake = _RejectingSession()
+        monkeypatch.setattr(
+            b4, 'get_patchwork_session', lambda key, url: (fake, 'https://pw/api')
+        )
+        _install_series(monkeypatch, [_mk_series(1)])
+        app = PwApp('k', 'https://pw.example.org', 'proj')
+        notices: List[str] = []
+        orig_notify = app.notify
+
+        def _notify(message: str, *a: Any, **k: Any) -> None:
+            notices.append(message)
+            orig_notify(message, *a, **k)
+
+        monkeypatch.setattr(app, 'notify', _notify)
+        async with app.run_test(size=(120, 30)) as pilot:
+            await app.workers.wait_for_complete()
+            await pilot.pause()
+            app.action_set_state()
+            await pilot.pause()
+            await app.screen.dismiss(('reviewing', False))
+            await pilot.pause()
+            await app.workers.wait_for_complete()
+            await pilot.pause()
+
+            assert app._all_series[0]['state'] == 'new'
+            assert (
+                '0 updated, 2 failed: state: Object with slug=reviewing does not exist.'
+            ) in notices

-- 
2.55.0


  reply	other threads:[~2026-10-15  0:04 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-15  0:03 [PATCH b4 0/4] review-tui: add support for setting the delegate in patchwork Brian Masney
2026-10-15  0:03 ` Brian Masney [this message]
2026-10-15  0:03 ` [PATCH b4 2/4] review-tui: let ApplyStateModal update other patch fields Brian Masney
2026-10-15  0:03 ` [PATCH b4 3/4] review: add pw_fetch_maintainers() Brian Masney
2026-10-15  0:03 ` [PATCH b4 4/4] review-tui: change the Patchwork delegate from the pw browser Brian Masney

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=20261014-pw-delegate-v1-1-a393ae92152a@kernel.org \
    --to=masneyb@kernel.org \
    --cc=jbrunet@baylibre.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