[PATCH b4 25/27] review-tui: keep post-send bookkeeping out of the review send error path
Christian Brauner <[email protected]> Fri, 31 Jul 2026 11:21:24 +0200
| Newsgroups | org.kernel.linux.tools |
|---|---|
| Message-ID | <20260731-work-b4-editor-branch-guard-v1-25-de68a7c8e4cb@kernel.org> |
The same shape the thank-you path just lost. action_send() does the SMTP handoff and everything after it -- the tracking write, the Answered flags, the outgoing-seen record -- inside one try whose handler reports "Send failed". The reviews are on the list at that point and we would be telling the maintainer to send them again. Here the bookkeeping mostly swallows its own failures, and that turns out to be the worse half of the problem. _save_tracking() drops the bool save_tracking_ref() hands back, so a tracking write that does not land is reported as nothing at all. The next session reads the branch without a sent-revision on any review, offers all of them as unsent, and the maintainer sends the series twice. Split the two. Only the SMTP call keeps that handler; once send_mail() has returned we report the outcome and run the rest under its own, which has to catch rather than propagate because this is a screen callback. Pass _save_tracking()'s answer up and say so when it is False. The two quick-reply paths get the same treatment. Nothing after the send can raise there today, but a tight try is what keeps that true, and it lets the branch that has already tested --email-dry-run stop passing it on to a helper that would test it again. Signed-off-by: Christian Brauner (Amutable) <[email protected]> --- src/b4/review_tui/_lite_app.py | 19 ++++++----- src/b4/review_tui/_review_app.py | 74 ++++++++++++++++++++++++---------------- 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/src/b4/review_tui/_lite_app.py b/src/b4/review_tui/_lite_app.py index 951057f..51e1437 100644 --- a/src/b4/review_tui/_lite_app.py +++ b/src/b4/review_tui/_lite_app.py @@ -813,17 +813,18 @@ class LiteThreadScreen(ModalScreen[None]): output_dir=None, reflect=False, ) - if sent is None: - self.app.notify('Failed to send reply.', severity='error') - elif self._email_dryrun: - self.app.notify(f'Dry-run: reply to {lmsg.fromemail} logged, not sent') - self._mark_answered(node) - else: - mark_outgoing_seen([msg], dryrun=self._email_dryrun) - self.app.notify(f'Reply sent to {lmsg.fromemail}') - self._mark_answered(node) except Exception as ex: self.app.notify(f'Send failed: {ex}', severity='error') + return + if sent is None: + self.app.notify('Failed to send reply.', severity='error') + return + if self._email_dryrun: + self.app.notify(f'Dry-run: reply to {lmsg.fromemail} logged, not sent') + else: + mark_outgoing_seen([msg]) + self.app.notify(f'Reply sent to {lmsg.fromemail}') + self._mark_answered(node) def action_back(self) -> None: if self._thread_nodes: diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py index 0982f55..1f38325 100644 --- a/src/b4/review_tui/_review_app.py +++ b/src/b4/review_tui/_review_app.py @@ -1654,27 +1654,42 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]): output_dir=None, reflect=False, ) - if sent is None: - self.notify('Failed to send review emails.', severity='error') - else: - self._reply_sent = True - self._tracking['series']['status'] = 'replied' - # Stamp sent-revision on every non-skip review 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', '[email protected]')) - current_rev = int(self._series.get('revision', 1)) - for target in [self._series] + list(self._patches): - review = target.get('reviews', {}).get(my_email, {}) - if review and review.get('patch-state') != 'skip': - review['sent-revision'] = current_rev - self._save_tracking() - self._mark_patches_answered(msgs) - mark_outgoing_seen(msgs, dryrun=self._email_dryrun) - self.notify(f'Sent {sent} review email(s).') except Exception as ex: self.notify(f'Send failed: {ex}', severity='error') + return + if sent is None: + self.notify('Failed to send review emails.', severity='error') + return + self.notify(f'Sent {sent} review email(s).') + + # The mail is out; everything below only records that. It gets its + # own handler so a bookkeeping failure is never reported as a send + # failure, and never escapes this screen callback. + try: + self._reply_sent = True + self._tracking['series']['status'] = 'replied' + # Stamp sent-revision on every non-skip review 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', '[email protected]')) + current_rev = int(self._series.get('revision', 1)) + for target in [self._series] + list(self._patches): + review = target.get('reviews', {}).get(my_email, {}) + if review and review.get('patch-state') != 'skip': + review['sent-revision'] = current_rev + if not self._save_tracking(): + # Silence here means the next session offers these reviews + # as unsent and the maintainer sends them twice. + self.notify( + f'Sent, but could not record it on {self._branch}', + severity='warning', + ) + self._mark_patches_answered(msgs) + mark_outgoing_seen(msgs, dryrun=self._email_dryrun) + except Exception as ex: + logger.debug('Post-send bookkeeping failed: %s', ex, exc_info=True) + self.notify(f'Sent, but recording it failed: {ex}', severity='warning') self.push_screen(SendScreen(msgs), _on_send_confirmed) @@ -1751,15 +1766,16 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]): output_dir=None, reflect=False, ) - if sent is None: - self.notify('Failed to send reply.', severity='error') - elif self._email_dryrun: - self.notify(f'Dry-run: reply to {entry["fromemail"]} logged, not sent') - else: - mark_outgoing_seen([msg], dryrun=self._email_dryrun) - self.notify(f'Reply sent to {entry["fromemail"]}') except Exception as ex: self.notify(f'Send failed: {ex}', severity='error') + return + if sent is None: + self.notify('Failed to send reply.', severity='error') + elif self._email_dryrun: + self.notify(f'Dry-run: reply to {entry["fromemail"]} logged, not sent') + else: + mark_outgoing_seen([msg]) + self.notify(f'Reply sent to {entry["fromemail"]}') def _load_followup_msgs(self, msgs: List[Any]) -> None: """Parse msgs into follow-up comments and refresh the display.""" @@ -2240,9 +2256,9 @@ class ReviewApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[None]): self.notify('Agent review data loaded') self._restore_original_branch() - def _save_tracking(self) -> None: - """Save tracking data to the review branch.""" - b4.review.save_tracking_ref( + def _save_tracking(self) -> bool: + """Save tracking data to the review branch. True if it landed.""" + return b4.review.save_tracking_ref( self._topdir, self._branch, self._cover_text, self._tracking ) -- 2.53.0