* [PATCH b4 1/4] review-tui: show why a Patchwork update failed
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
2026-10-15 0:03 ` [PATCH b4 2/4] review-tui: let ApplyStateModal update other patch fields Brian Masney
` (2 subsequent siblings)
3 siblings, 0 replies; 5+ messages in thread
From: Brian Masney @ 2026-10-15 0:03 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Jerome Brunet, Brian Masney
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
^ permalink raw reply related [flat|nested] 5+ messages in thread* [PATCH b4 3/4] review: add pw_fetch_maintainers()
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 ` [PATCH b4 1/4] review-tui: show why a Patchwork update failed Brian Masney
2026-10-15 0:03 ` [PATCH b4 2/4] review-tui: let ApplyStateModal update other patch fields Brian Masney
@ 2026-10-15 0:03 ` Brian Masney
2026-10-15 0:03 ` [PATCH b4 4/4] review-tui: change the Patchwork delegate from the pw browser Brian Masney
3 siblings, 0 replies; 5+ messages in thread
From: Brian Masney @ 2026-10-15 0:03 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Jerome Brunet, Brian Masney
Patchwork only accepts a project maintainer as a patch delegate, so the
project's maintainers are the users that a delegate picker should offer.
Add a helper that reads them from the project endpoint.
Assisted-by: LLM
Signed-off-by: Brian Masney <masneyb@kernel.org>
---
src/b4/review/_review.py | 57 +++++++++++++++++++++++++++++++++++++++
src/tests/test_tui_pw.py | 69 ++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 126 insertions(+)
diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index b69a7f094bc7..a65b6a4f549b 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -4152,6 +4152,63 @@ 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_lookup_user_id(
+ pses: requests.Session, api_url: str, username: str
+) -> Optional[int]:
+ """Return the Patchwork User id for *username*, or None if not found.
+
+ The users endpoint searches username, names and email by substring, so
+ pick out the exact username match.
+ """
+ users_url = '/'.join((api_url, 'users', ''))
+ resp = pses.get(users_url, params={'q': username})
+ if resp.status_code != 200:
+ raise RuntimeError(f'Patchwork API error: {resp.status_code} {resp.reason}')
+ for u in resp.json():
+ if u.get('username') == username and u.get('id'):
+ return int(u['id'])
+ return None
+
+
+def pw_fetch_maintainers(pwkey: str, pwurl: str, pwproj: str) -> List[Dict[str, Any]]:
+ """Return the maintainers of a Patchwork project.
+
+ Patchwork only accepts a project maintainer as a patch delegate, so
+ this is the list of users a patch can be delegated to.
+
+ Returns a list of user dicts with 'id', 'username', 'name' and 'email'
+ keys, sorted by username. The 'id' is the User id that a patch's
+ delegate field takes.
+ """
+ pses, api_url = b4.get_patchwork_session(pwkey, pwurl)
+ # The projects endpoint accepts the project linkname in place of its id.
+ project_url = '/'.join((api_url, 'projects', pwproj, ''))
+ resp = pses.get(project_url)
+ if resp.status_code != 200:
+ raise RuntimeError(f'Patchwork API error: {resp.status_code} {resp.reason}')
+ users: List[Dict[str, Any]] = []
+ for m in resp.json().get('maintainers', []):
+ username = m.get('username')
+ if not username:
+ continue
+ # The maintainers list carries UserProfile ids, not the User ids
+ # that the delegate field expects, so resolve each one by username.
+ user_id = _pw_lookup_user_id(pses, api_url, username)
+ if user_id is None:
+ logger.debug('Could not find Patchwork user %s', username)
+ continue
+ name = ' '.join(p for p in (m.get('first_name'), m.get('last_name')) if p)
+ users.append(
+ {
+ 'id': user_id,
+ 'username': username,
+ 'name': name,
+ 'email': m.get('email') or '',
+ }
+ )
+ return sorted(users, key=lambda u: u['username'].lower())
+
+
def pw_fetch_checks(
pwkey: str, pwurl: str, patch_ids: List[int]
) -> List[Dict[str, Any]]:
diff --git a/src/tests/test_tui_pw.py b/src/tests/test_tui_pw.py
index c7af958e895f..1d7f62681f26 100644
--- a/src/tests/test_tui_pw.py
+++ b/src/tests/test_tui_pw.py
@@ -930,3 +930,72 @@ class TestPwApplyErrorText:
assert (
'0 updated, 2 failed: state: Object with slug=reviewing does not exist.'
) in notices
+
+
+# ---------------------------------------------------------------------------
+# Changing the delegate
+# ---------------------------------------------------------------------------
+
+
+class TestPwFetchMaintainers:
+ """pw_fetch_maintainers reads the project's maintainers list."""
+
+ def test_resolves_user_ids(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The maintainers list carries UserProfile ids; the delegate field
+ wants User ids, so each maintainer is looked up by username."""
+
+ class _Resp:
+ status_code = 200
+ reason = 'OK'
+
+ def __init__(self, body: Any) -> None:
+ self._body = body
+
+ def json(self) -> Any:
+ return self._body
+
+ # Users endpoint search is a substring match across several fields.
+ users_db: List[Dict[str, Any]] = [
+ {'id': 101, 'username': 'zed'},
+ {'id': 102, 'username': 'amy'},
+ {'id': 103, 'username': 'amyx'},
+ ]
+
+ class _Session:
+ def __init__(self) -> None:
+ self.calls: List[Any] = []
+
+ def get(self, url: str, params: Optional[Dict[str, str]] = None) -> _Resp:
+ self.calls.append((url, params))
+ if url.endswith('/projects/proj/'):
+ return _Resp(
+ {
+ 'maintainers': [
+ {
+ 'id': 2,
+ 'username': 'zed',
+ 'first_name': 'Zed',
+ 'last_name': 'Zee',
+ 'email': 'z@x',
+ },
+ {'id': 1, 'username': 'amy', 'email': 'a@x'},
+ {'id': 3, 'username': 'ghost', 'email': 'g@x'},
+ ]
+ }
+ )
+ assert url.endswith('/users/')
+ assert params is not None
+ q = params['q']
+ return _Resp([u for u in users_db if q in u['username']])
+
+ sess = _Session()
+ monkeypatch.setattr(
+ b4, 'get_patchwork_session', lambda key, url: (sess, 'https://pw/api/1.2')
+ )
+ users = b4.review.pw_fetch_maintainers('k', 'https://pw', 'proj')
+ assert sess.calls[0] == ('https://pw/api/1.2/projects/proj/', None)
+ # 'ghost' has no matching user and is left out.
+ assert users == [
+ {'id': 102, 'username': 'amy', 'name': '', 'email': 'a@x'},
+ {'id': 101, 'username': 'zed', 'name': 'Zed Zee', 'email': 'z@x'},
+ ]
--
2.55.0
^ permalink raw reply related [flat|nested] 5+ messages in thread* [PATCH b4 4/4] review-tui: change the Patchwork delegate from the pw browser
2026-10-15 0:03 [PATCH b4 0/4] review-tui: add support for setting the delegate in patchwork Brian Masney
` (2 preceding siblings ...)
2026-10-15 0:03 ` [PATCH b4 3/4] review: add pw_fetch_maintainers() Brian Masney
@ 2026-10-15 0:03 ` Brian Masney
3 siblings, 0 replies; 5+ messages in thread
From: Brian Masney @ 2026-10-15 0:03 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Jerome Brunet, Brian Masney
The Patchwork browser shows who a series is delegated to, and can filter
on it, but changing the delegate still means going to the web UI. Add a
'd' key that offers the project's maintainers, plus "(no delegate)", and
sets the chosen one on every patch in the series. Like 's', it acts on
every marked series when any are marked, asks for confirmation first,
and can be stopped with Esc.
Assisted-by: LLM
Signed-off-by: Brian Masney <masneyb@kernel.org>
---
docs/maintainer/review.rst | 6 ++
src/b4/review_tui/_modals.py | 168 +++++++++++++++++++++++++++++++++++++++++++
src/b4/review_tui/_pw_app.py | 160 ++++++++++++++++++++++++++++++++++++++++-
src/tests/test_tui_pw.py | 155 +++++++++++++++++++++++++++++++++++++++
4 files changed, 488 insertions(+), 1 deletion(-)
diff --git a/docs/maintainer/review.rst b/docs/maintainer/review.rst
index fc708dd82055..d493460576ce 100644
--- a/docs/maintainer/review.rst
+++ b/docs/maintainer/review.rst
@@ -826,6 +826,7 @@ there you can:
* View CI check details for a series (``c``)
* Track a series directly from the Patchwork list (``t``)
* Set or change the Patchwork state for a series (``s``)
+* Change the Patchwork delegate for a series (``d``)
* Hide and unhide series (``h``/``u``)
* Toggle display of hidden series (``H``)
* Refresh the series list from Patchwork (``r``)
@@ -843,6 +844,11 @@ To set the state of many series at once, mark them first (``space``, or
changes anything. While the states are being set, press ``Esc`` to stop
after the current patch.
+Press ``d`` to delegate a series to another project maintainer, or to
+clear its delegate. Patchwork only accepts project maintainers as
+delegates, so the list offers only those. Like ``s``, it acts on every
+marked series when any are marked.
+
B4 also keeps Patchwork up to date as you work through the review
lifecycle. This works for any tracked series that Patchwork knows
about, not only the series you track from the Patchwork browser. B4
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index bcdbba2250cc..1d2e82c133c7 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -361,6 +361,7 @@ PW_HELP_LINES = [
' [bold]c[/bold] View CI check details\n',
' [bold]t[/bold] Track series for review\n',
' [bold]s[/bold] Set state (marked series, or current)\n',
+ ' [bold]d[/bold] Set delegate (marked series, or current)\n',
' [bold]h[/bold] Hide series\n',
' [bold]u[/bold] Unhide series (when showing hidden)\n',
'\n',
@@ -2711,6 +2712,143 @@ def SetStateConfirmScreen(
)
+class DelegateOption(ListItem):
+ """A selectable user entry in the set-delegate dialog.
+
+ A *user_id* of None is the "no delegate" entry.
+ """
+
+ def __init__(
+ self,
+ user_id: Optional[int],
+ username: str,
+ label: str,
+ highlighted: bool = False,
+ ) -> None:
+ super().__init__()
+ self.user_id = user_id
+ self.username = username
+ self._label = label
+ self._highlighted = highlighted
+
+ def compose(self) -> ComposeResult:
+ marker = '> ' if self._highlighted else ' '
+ yield Label(f'{marker}{self._label}', markup=False)
+
+
+class SetDelegateScreen(
+ JKListNavMixin, ModalScreen[Optional[Tuple[Optional[int], str]]]
+):
+ """Modal to pick a new Patchwork delegate for a series.
+
+ *users* are the project maintainers, as returned by
+ :func:`b4.review.pw_fetch_maintainers`. Returns (user_id, username) on
+ confirm, with (None, '') meaning "clear the delegate", or None on cancel.
+ """
+
+ _list_id = '#delegate-list'
+
+ BINDINGS = [
+ Binding('j', 'cursor_down', 'Down', show=False),
+ Binding('k', 'cursor_up', 'Up', show=False),
+ Binding('enter', 'confirm', 'Confirm'),
+ Binding('escape', 'cancel', 'Cancel'),
+ Binding('q', 'cancel', 'Cancel', show=False),
+ ]
+
+ DEFAULT_CSS = """
+ SetDelegateScreen {
+ align: center middle;
+ }
+ #delegate-dialog {
+ width: 70;
+ height: auto;
+ max-height: 80%;
+ border: solid $accent;
+ background: $surface;
+ padding: 1 2;
+ }
+ #delegate-list {
+ height: auto;
+ max-height: 20;
+ }
+ """
+
+ def __init__(self, users: List[Dict[str, Any]], current_delegate: str) -> None:
+ super().__init__()
+ self._users = users
+ self._current_delegate = current_delegate
+
+ def compose(self) -> ComposeResult:
+ options = [
+ DelegateOption(None, '', '(no delegate)', not self._current_delegate)
+ ]
+ for u in self._users:
+ label = u['username']
+ if u.get('name'):
+ label = f'{label} ({u["name"]})'
+ options.append(
+ DelegateOption(
+ u['id'],
+ u['username'],
+ label,
+ u['username'] == self._current_delegate,
+ )
+ )
+ with Vertical(id='delegate-dialog'):
+ yield Label('Set delegate (Enter=confirm, Esc=cancel):')
+ yield ListView(*options, id='delegate-list')
+
+ def on_mount(self) -> None:
+ lv = self.query_one('#delegate-list', ListView)
+ lv.focus()
+ # Pre-select the current delegate
+ for i, child in enumerate(lv.children):
+ if (
+ isinstance(child, DelegateOption)
+ and child.username == self._current_delegate
+ ):
+ lv.index = i
+ break
+
+ def action_confirm(self) -> None:
+ self._do_confirm()
+
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
+ event.stop()
+ self._do_confirm()
+
+ def _do_confirm(self) -> None:
+ lv = self.query_one('#delegate-list', ListView)
+ child = lv.highlighted_child
+ if not isinstance(child, DelegateOption):
+ self.dismiss(None)
+ return
+ self.dismiss((child.user_id, child.username))
+
+ def action_cancel(self) -> None:
+ self.dismiss(None)
+
+
+def SetDelegateConfirmScreen(
+ num_series: int, num_patches: int, delegate: str
+) -> ConfirmScreen:
+ """Build a confirmation screen for a bulk Patchwork delegate change."""
+ if delegate:
+ subject = f'Delegating {num_series} series to "{delegate}"'
+ else:
+ subject = f'Clearing the delegate of {num_series} series'
+ return ConfirmScreen(
+ title='Set Delegate',
+ body=[
+ f'{num_patches} patches will be updated on the Patchwork server.',
+ '',
+ 'Are you sure?',
+ ],
+ subject=subject,
+ )
+
+
# Seconds to wait for one Patchwork PATCH. Esc can only stop the run
# between requests, so a hung request must not block it for ever.
PW_PATCH_TIMEOUT = 30
@@ -2903,6 +3041,36 @@ class ApplyStateModal(ModalScreen[ApplyStateResult]):
self.dismiss(self._result())
+class ApplyDelegateModal(ApplyStateModal):
+ """Modal showing progress while changing the delegate of patches.
+
+ A *delegate_id* of None clears the delegate. The result's
+ ``new_state`` carries the new delegate's username ('' when cleared).
+ """
+
+ def __init__(
+ self,
+ pwkey: str,
+ pwurl: str,
+ patch_ids: List[int],
+ delegate_id: Optional[int],
+ delegate_name: str,
+ series_name: str,
+ ) -> None:
+ super().__init__(pwkey, pwurl, patch_ids, delegate_name, False, series_name)
+ self._delegate_id = delegate_id
+
+ def _title(self) -> str:
+ if self._delegate_id is None:
+ return 'Clearing delegate'
+ return f'Setting delegate to: {self._new_state}'
+
+ def _payload(self) -> Dict[str, Any]:
+ # Patchwork treats an empty relational field as null, which clears
+ # the delegate.
+ return {'delegate': '' if self._delegate_id is None else self._delegate_id}
+
+
class UpdateAllScreen(ModalScreen[Dict[str, Any]]):
"""Modal showing progress while updating all tracked series.
diff --git a/src/b4/review_tui/_pw_app.py b/src/b4/review_tui/_pw_app.py
index 6e99488d7e35..27eb50c191c3 100644
--- a/src/b4/review_tui/_pw_app.py
+++ b/src/b4/review_tui/_pw_app.py
@@ -38,11 +38,14 @@ from b4.review_tui._common import (
)
from b4.review_tui._modals import (
PW_HELP_LINES,
+ ApplyDelegateModal,
ApplyStateModal,
ApplyStateResult,
CIChecksScreen,
HelpScreen,
LimitScreen,
+ SetDelegateConfirmScreen,
+ SetDelegateScreen,
SetStateConfirmScreen,
SetStateScreen,
)
@@ -263,6 +266,7 @@ class PwApp(LoreNodeShutdownMixin, App[None]):
'track_series': 'Series',
'toggle_mark': 'Series',
'set_state': 'Series',
+ 'set_delegate': 'Series',
'hide_series': 'Series',
'refresh': 'App',
'limit': 'App',
@@ -283,6 +287,7 @@ class PwApp(LoreNodeShutdownMixin, App[None]):
Binding('t', 'track_series', 'track'),
Binding('space', 'toggle_mark', 'mark', key_display='space'),
Binding('s', 'set_state', 'set state'),
+ Binding('d', 'set_delegate', 'delegate'),
Binding('h', 'hide_series', 'hide'),
# App-global actions
Binding('r', 'refresh', 'refresh'),
@@ -327,6 +332,10 @@ class PwApp(LoreNodeShutdownMixin, App[None]):
# large-backlog notice this session (it pops up once, not every reload).
self._backlog_count: int = 0
self._backlog_notice_shown: bool = False
+ # Project maintainers (the users a patch can be delegated to),
+ # fetched the first time the delegate picker is opened.
+ self._maintainers: Optional[List[Dict[str, Any]]] = None
+ self._delegate_targets: List[Dict[str, Any]] = []
self._load_local_data()
self._load_tracking_data()
@@ -467,6 +476,16 @@ class PwApp(LoreNodeShutdownMixin, App[None]):
if isinstance(err, liblore.OperationCancelledError):
return
self.notify(f'Error retrieving series: {err}', severity='error')
+ elif event.worker.name == '_fetch_maintainers':
+ if event.state == WorkerState.SUCCESS:
+ self._maintainers = event.worker.result or []
+ self._open_delegate_picker()
+ elif event.state == WorkerState.ERROR:
+ self._delegate_targets = []
+ self.notify(
+ f'Error fetching project maintainers: {event.worker.error}',
+ severity='error',
+ )
async def _populate(self, series_list: List[Dict[str, Any]]) -> None:
loading = self.query('#pw-loading')
@@ -729,7 +748,7 @@ class PwApp(LoreNodeShutdownMixin, App[None]):
return None
def _state_targets(self) -> List[Dict[str, Any]]:
- """Series the set-state action applies to.
+ """Series the set-state and set-delegate actions apply to.
All marked series when any are marked, otherwise the highlighted row.
Marked series are resolved from the full listing so the action still
@@ -847,6 +866,145 @@ class PwApp(LoreNodeShutdownMixin, App[None]):
item.refresh_label()
self._update_title_selection()
+ def action_set_delegate(self) -> None:
+ """Change the Patchwork delegate of the marked (or highlighted) series."""
+ targets = self._state_targets()
+ if not targets:
+ return
+ self._delegate_targets = targets
+ if self._maintainers is None:
+ self.notify('Fetching project maintainers…', timeout=3)
+ self.run_worker(
+ self._fetch_maintainers,
+ name='_fetch_maintainers',
+ thread=True,
+ exit_on_error=False,
+ )
+ return
+ self._open_delegate_picker()
+
+ def _fetch_maintainers(self) -> List[Dict[str, Any]]:
+ from b4.review_tui._common import _quiet_worker
+
+ with _quiet_worker():
+ return b4.review.pw_fetch_maintainers(
+ self._pwkey, self._pwurl, self._pwproj
+ )
+
+ def _open_delegate_picker(self) -> None:
+ targets = self._delegate_targets
+ self._delegate_targets = []
+ if not targets:
+ return
+ if not self._maintainers:
+ self.notify(
+ 'This Patchwork project has no maintainers to delegate to',
+ severity='warning',
+ )
+ return
+ item = self._get_highlighted_item()
+ current = (item.series.get('delegate') or '') if item is not None else ''
+ self.push_screen(
+ SetDelegateScreen(self._maintainers, current),
+ callback=lambda result: self._on_set_delegate(result, targets),
+ )
+
+ def _on_set_delegate(
+ self,
+ result: Optional[Tuple[Optional[int], str]],
+ targets: List[Dict[str, Any]],
+ ) -> None:
+ if result is None:
+ return
+ user_id, username = result
+ patch_ids: List[int] = []
+ for s in targets:
+ patch_ids.extend(s.get('patch_ids', []))
+ if not patch_ids:
+ self.notify('No patch IDs for selected series', severity='warning')
+ return
+
+ def _apply(label: str) -> None:
+ self.push_screen(
+ ApplyDelegateModal(
+ self._pwkey, self._pwurl, patch_ids, user_id, username, label
+ ),
+ callback=lambda res: self._on_delegate_complete(res, targets),
+ )
+
+ if len(targets) == 1:
+ _apply(targets[0].get('name') or '(no subject)')
+ return
+
+ # Several series in one go: let the user check the count first.
+ label = f'{len(targets)} series ({len(patch_ids)} patches)'
+
+ def _on_confirm(confirmed: Optional[bool]) -> None:
+ if confirmed:
+ _apply(label)
+
+ self.push_screen(
+ SetDelegateConfirmScreen(len(targets), len(patch_ids), username),
+ callback=_on_confirm,
+ )
+
+ async def _on_delegate_complete(
+ self, result: Optional[ApplyStateResult], targets: List[Dict[str, Any]]
+ ) -> None:
+ assert result is not None
+ delegate = result.new_state
+ what = f'delegated to {delegate}' if delegate else 'undelegated'
+ # As with set state, only a series whose every patch was updated
+ # takes the new delegate; the rest stay marked for another try.
+ done = [
+ s
+ for s in targets
+ if s.get('patch_ids')
+ and all(pid in result.done_ids for pid in s['patch_ids'])
+ ]
+ ok = len(result.done_ids)
+ if result.cancelled:
+ self.notify(
+ f'Stopped: {len(done)} of {len(targets)} series {what} ({ok} patches)',
+ severity='warning',
+ )
+ elif result.fail:
+ 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) {what}', severity='information')
+ else:
+ self.notify(
+ f'{len(targets)} series {what} ({ok} patches)',
+ severity='information',
+ )
+ for s in done:
+ s['delegate'] = delegate
+ self._selected_ids.discard(s.get('id'))
+
+ items = self._visible_items()
+ # The delegate column only shows when the visible rows disagree, so a
+ # change can make it appear or disappear: rebuild the list then.
+ show_delegate = len({it.series.get('delegate') or '' for it in items}) > 1
+ if items and show_delegate != items[0].show_delegate:
+ lv = self.query_one('#pw-list', ListView)
+ index = lv.index
+ await self._refresh_list()
+ if index is not None:
+ try:
+ self.query_one('#pw-list', ListView).index = index
+ except Exception:
+ pass
+ return
+ done_ids = {s.get('id') for s in done}
+ for item in items:
+ if item.series.get('id') in done_ids:
+ item.selected = False
+ item.refresh_label()
+ self._update_title_selection()
+
def action_track_series(self) -> None:
if not self._tracking_enabled:
self.notify(
diff --git a/src/tests/test_tui_pw.py b/src/tests/test_tui_pw.py
index 1d7f62681f26..af3ac9d38af5 100644
--- a/src/tests/test_tui_pw.py
+++ b/src/tests/test_tui_pw.py
@@ -21,6 +21,7 @@ import requests
pytest.importorskip('textual')
from textual.widgets import Label, ListView, ProgressBar
+from textual.worker import WorkerFailed
import b4
import b4.review
@@ -31,6 +32,7 @@ from b4.review_tui._modals import (
PW_PATCH_TIMEOUT,
ApplyStateModal,
ConfirmScreen,
+ SetDelegateScreen,
SetStateScreen,
)
from b4.review_tui._pw_app import PwApp, PwFetchProgress
@@ -493,6 +495,7 @@ class _FakePwSession:
def __init__(self) -> None:
self.patched: List[str] = []
+ self.payloads: List[Any] = []
self.timeouts: List[Optional[float]] = []
def patch(
@@ -503,6 +506,7 @@ class _FakePwSession:
timeout: Optional[float] = None,
) -> _FakeResp:
self.patched.append(url)
+ self.payloads.append(data)
self.timeouts.append(timeout)
return _FakeResp()
@@ -937,6 +941,157 @@ class TestPwApplyErrorText:
# ---------------------------------------------------------------------------
+def _pw_maintainers() -> List[Dict[str, Any]]:
+ return [
+ {'id': 7, 'username': 'alice', 'name': 'Alice A', 'email': 'a@example.com'},
+ {'id': 9, 'username': 'bob', 'name': '', 'email': 'b@example.com'},
+ ]
+
+
+class TestPwSetDelegate:
+ """`d` delegates the marked (or highlighted) series to a maintainer."""
+
+ def _install(
+ self, monkeypatch: pytest.MonkeyPatch, series: List[Dict[str, Any]]
+ ) -> _FakePwSession:
+ fake = _FakePwSession()
+ monkeypatch.setattr(
+ b4, 'get_patchwork_session', lambda key, url: (fake, 'https://pw/api')
+ )
+ self.maintainer_calls = 0
+
+ def _maintainers(*a: Any, **k: Any) -> List[Dict[str, Any]]:
+ self.maintainer_calls += 1
+ return _pw_maintainers()
+
+ monkeypatch.setattr(b4.review, 'pw_fetch_maintainers', _maintainers)
+ _install_series(monkeypatch, series)
+ return fake
+
+ @pytest.mark.asyncio
+ async def test_delegate_highlighted(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ fake = self._install(monkeypatch, [_mk_series(1), _mk_series(2)])
+ app = PwApp('k', 'https://pw.example.org', 'proj')
+ async with app.run_test(size=(120, 30)) as pilot:
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ app.query_one('#pw-list', ListView).index = 1
+ await pilot.press('d')
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ assert isinstance(app.screen, SetDelegateScreen)
+ # "(no delegate)" first, then the maintainers.
+ lv = app.screen.query_one('#delegate-list', ListView)
+ assert len(lv.children) == 3
+ await app.screen.dismiss((7, 'alice'))
+ await pilot.pause()
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+
+ by_id = {s['id']: s for s in app._all_series}
+ assert by_id[1].get('delegate', '') == ''
+ assert by_id[2]['delegate'] == 'alice'
+ assert fake.patched == [
+ 'https://pw/api/patches/20/',
+ 'https://pw/api/patches/21/',
+ ]
+ assert fake.payloads == [{'delegate': 7}, {'delegate': 7}]
+ # Delegates now differ, so the delegate column shows up.
+ assert all(it.show_delegate for it in app._visible_items())
+ # The cursor stays on the row that was changed.
+ assert app.query_one('#pw-list', ListView).index == 1
+
+ # The maintainers are fetched once and reused.
+ await pilot.press('d')
+ await pilot.pause()
+ assert isinstance(app.screen, SetDelegateScreen)
+ assert self.maintainer_calls == 1
+
+ @pytest.mark.asyncio
+ async def test_bulk_delegate_confirms(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ fake = self._install(monkeypatch, [_mk_series(1), _mk_series(2), _mk_series(3)])
+ app = PwApp('k', 'https://pw.example.org', 'proj')
+ async with app.run_test(size=(120, 30)) as pilot:
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ app.action_toggle_mark()
+ app.action_toggle_mark()
+ await pilot.pause()
+ assert app._selected_ids == {1, 2}
+ app.action_set_delegate()
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ await app.screen.dismiss((9, 'bob'))
+ await pilot.pause()
+ assert isinstance(app.screen, ConfirmScreen)
+ assert (
+ static_text(app.screen.query_one('#confirm-title'))
+ == 'Delegating 2 series to "bob"'
+ )
+ assert fake.patched == []
+ await pilot.press('y')
+ await pilot.pause()
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+
+ by_id = {s['id']: s for s in app._all_series}
+ assert by_id[1]['delegate'] == 'bob'
+ assert by_id[2]['delegate'] == 'bob'
+ assert by_id[3].get('delegate', '') == ''
+ assert len(fake.patched) == 4
+ assert app._selected_ids == set()
+
+ @pytest.mark.asyncio
+ async def test_clear_delegate(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ series = _mk_series(1)
+ series['delegate'] = 'alice'
+ fake = self._install(monkeypatch, [series])
+ app = PwApp('k', 'https://pw.example.org', 'proj')
+ async with app.run_test(size=(120, 30)) as pilot:
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ app.action_set_delegate()
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ screen = app.screen
+ assert isinstance(screen, SetDelegateScreen)
+ # The current delegate is pre-selected.
+ lv = screen.query_one('#delegate-list', ListView)
+ assert lv.index == 1
+ lv.index = 0
+ await pilot.press('enter')
+ await pilot.pause()
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+
+ assert app._all_series[0]['delegate'] == ''
+ assert fake.payloads == [{'delegate': ''}, {'delegate': ''}]
+
+ @pytest.mark.asyncio
+ async def test_maintainer_fetch_error(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ self._install(monkeypatch, [_mk_series(1)])
+
+ def _boom(*a: Any, **k: Any) -> List[Dict[str, Any]]:
+ raise RuntimeError('Patchwork API error: 404 Not Found')
+
+ monkeypatch.setattr(b4.review, 'pw_fetch_maintainers', _boom)
+ app = PwApp('k', 'https://pw.example.org', 'proj')
+ async with app.run_test(size=(120, 30)) as pilot:
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ app.action_set_delegate()
+ with pytest.raises(WorkerFailed):
+ await app.workers.wait_for_complete()
+ await pilot.pause()
+ assert not isinstance(app.screen, SetDelegateScreen)
+ # A later press tries the fetch again.
+ assert app._maintainers is None
+
+
class TestPwFetchMaintainers:
"""pw_fetch_maintainers reads the project's maintainers list."""
--
2.55.0
^ permalink raw reply related [flat|nested] 5+ messages in thread