emacs-patch-review

Port of Thunderbird Patch Review to mu4e.

git clone git://mccd.space/emacs-patch-review
commit 4fb28b5bc07c31c5476bb511edc4bdefba722e9e
parent f9d6e8ca8bc3cf51a1d67330b0e5bd0a9e60f905
Author: Pi Agent <agent@pi.local>
Date:   Mon,  3 Aug 2026 10:20:19 +0200

Add patch-review-mode: review buffer, extraction, compose, apply

The review buffer is an editable diff-mode buffer holding the patch.
There is no comment command: inserted text anywhere is commentary.
On C-c C-c the buffer is diffed (-U0) against the pristine original;
pure insertions become comments anchored to the original line above
them (mapped through the parse tree to file:hunk:line locators),
insertions above the first hunk become the general remark, and
rewrites/deletions of patch text are counted and confirmed. The reply
is composed from the pristine text via message-mode with To/Subject/
In-Reply-To reconstructed from the source headers.

The header line carries the two primary buttons ([Send review],
[Apply]) plus [Refresh], the target project, and the apply status.
C-c C-a applies with git am (C-u suppresses hooks). Opening a
.patch/.diff file activates the mode via auto-mode-alist.

Diffstat:
Apatch-review.el | 519+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/patch-review-test.el | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 705 insertions(+), 0 deletions(-)
diff --git a/patch-review.el b/patch-review.el
@@ -0,0 +1,519 @@
+;;; patch-review.el --- Review git patches from email -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Marc Coquand
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; Package-Requires: ((emacs "28.1"))
+;; Version: 0.1.0
+
+;;; Commentary:
+
+;; A major mode (derived from `diff-mode') for reviewing git patches
+;; received by email.  The review buffer is an ordinary, editable diff
+;; buffer: type your commentary anywhere in it.  On send (C-c C-c), the
+;; buffer is diffed against the pristine original; your insertions are
+;; extracted, anchored to the patch line above them, and formatted as an
+;; interleaved mailing-list reply.  C-c C-a applies the patch to a local
+;; repository with git-am(1).
+;;
+;; Entry points:
+;;   M-x patch-review-open-message-file  — raw .eml / format-patch file
+;;   patch-review-mu4e-review            — from a mu4e view buffer
+;;   opening a .patch/.diff file         — via auto-mode-alist
+;;
+;; This is an Emacs port of the Thunderbird Patch Review add-on
+;; (thunderbird-review-ui).
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'subr-x)
+(require 'diff-mode)
+(require 'message)
+(require 'mailheader)
+(require 'project)
+(require 'patch-review-parse)
+(require 'patch-review-reply)
+(require 'patch-review-git)
+
+;;;; Customization
+
+(defgroup patch-review nil
+  "Review git patches from email."
+  :group 'tools
+  :prefix "patch-review-")
+
+(defcustom patch-review-project nil
+  "Repository to probe and apply patches against.
+When nil, fall back to the current project (per `project-current');
+\\[patch-review-set-project] sets it per buffer."
+  :type '(choice (const :tag "Ask" nil) directory)
+  :group 'patch-review)
+
+(defface patch-review-status-ok
+  '((t :inherit success))
+  "Face for a positive apply status (applies cleanly / already applied)."
+  :group 'patch-review)
+
+(defface patch-review-status-warn
+  '((t :inherit warning))
+  "Face for a warning apply status (dirty worktree, no project)."
+  :group 'patch-review)
+
+(defface patch-review-status-error
+  '((t :inherit error))
+  "Face for a negative apply status (conflicts, am in progress)."
+  :group 'patch-review)
+
+;;;; Buffer state
+
+(defvar-local patch-review--parsed nil
+  "The `patch-review-email' struct for this buffer.")
+
+(defvar-local patch-review--pristine nil
+  "The buffer text as opened.  Comments are extracted by diffing the
+current buffer against this string.")
+
+(defvar-local patch-review--headers nil
+  "Alist of the source message's mail headers, per `mail-header-extract'.")
+
+(defvar-local patch-review--source-file nil
+  "File `git am' is run on for this buffer.")
+
+(defvar-local patch-review--project nil
+  "Repository this buffer's patch targets.")
+
+(defvar-local patch-review--status 'unknown
+  "Apply status: unknown, checking, applicable, applied, dirty,
+conflict, am-in-progress or no-project.")
+
+(defvar-local patch-review--status-detail nil
+  "Human-readable detail for `patch-review--status'.")
+
+(defvar patch-review--pending-init nil
+  "Dynamically bound initialization plist for `patch-review-mode'.
+`patch-review--open-text' binds this around the mode call so the
+mode's self-initialization can pick up headers, source file and
+project.")
+
+;;;; Mode
+
+(define-derived-mode patch-review-mode diff-mode "PatchReview"
+  "Major mode for reviewing a git patch.
+
+The buffer is editable: type comments anywhere.  On \\[patch-review-send-review]
+the buffer is diffed against the pristine original and your
+insertions are sent as an interleaved review reply.
+
+\\{patch-review-mode-map}"
+  (setq buffer-read-only nil)
+  (setq-local header-line-format '(:eval (patch-review--header-line)))
+  (patch-review--ensure-initialized))
+
+(define-key patch-review-mode-map (kbd "C-c C-c") #'patch-review-send-review)
+(define-key patch-review-mode-map (kbd "C-c C-a") #'patch-review-apply)
+(define-key patch-review-mode-map (kbd "C-c C-p") #'patch-review-set-project)
+(define-key patch-review-mode-map (kbd "C-c C-r") #'patch-review-refresh-status)
+
+(defun patch-review--ensure-initialized ()
+  "Initialize the review buffer from its text unless already done."
+  (unless patch-review--parsed
+    (let* ((pending patch-review--pending-init)
+           (text (buffer-substring-no-properties (point-min) (point-max)))
+           (headers (or (plist-get pending :headers)
+                        (car (patch-review--split-message text))))
+           (source (or (plist-get pending :source) buffer-file-name)))
+      (setq patch-review--pristine text
+            patch-review--parsed (patch-review-parse-email text)
+            patch-review--headers headers
+            patch-review--source-file source
+            patch-review--project (or (plist-get pending :project)
+                                      (patch-review--default-project)))
+      (if (and patch-review--project source)
+          (patch-review-refresh-status)
+        (setq patch-review--status 'no-project)))))
+
+(defun patch-review--default-project ()
+  "Default repository for new review buffers."
+  (or patch-review-project
+      (when-let* ((pr (project-current nil)))
+        (project-root pr))))
+
+;;;; Opening
+
+;;;###autoload
+(defun patch-review-open-message-file (file &optional project)
+  "Open raw patch email FILE (an .eml or format-patch file) for review."
+  (interactive "fPatch message file: ")
+  (patch-review--open-text
+   (with-temp-buffer
+     (insert-file-contents file)
+     (buffer-string))
+   (expand-file-name file)
+   project))
+
+(defun patch-review--open-text (text source-file project)
+  "Open TEXT (a raw message) in a new review buffer.
+SOURCE-FILE is what `git am' runs on; PROJECT overrides the
+target repository.  Return the review buffer."
+  (pcase-let ((`(,headers . ,body) (patch-review--split-message text)))
+    (unless (patch-review-patch-message-p (cdr (assq 'subject headers)) body)
+      (unless (y-or-n-p "This message does not look like a patch; open anyway? ")
+        (user-error "Aborted")))
+    (let ((buf (generate-new-buffer
+                (format "*Review: %s*"
+                        (or (patch-review--decode (cdr (assq 'subject headers)))
+                            (and source-file
+                                 (file-name-nondirectory source-file))
+                            "patch")))))
+      (with-current-buffer buf
+        (insert body)
+        (goto-char (point-min))
+        (let ((patch-review--pending-init
+               (list :headers headers :source source-file :project project)))
+          (patch-review-mode)))
+      (pop-to-buffer buf)
+      buf)))
+
+(defun patch-review--split-message (text)
+  "Split raw message TEXT into (HEADERS . BODY).
+HEADERS is nil when the text carries no mail header block (a bare
+.diff).  A leading mbox \"From \" line is skipped."
+  (with-temp-buffer
+    (insert text)
+    (goto-char (point-min))
+    (when (looking-at "From .*Mon Sep 17 00:00:00 2001$")
+      (forward-line 1))
+    (if (not (looking-at "[A-Za-z][A-Za-z0-9-]*:"))
+        (cons nil text)
+      (let* ((hdr-end (save-excursion
+                        (if (re-search-forward "^$" nil t)
+                            (line-beginning-position)
+                          (point-min))))
+             (headers (save-excursion
+                        (save-restriction
+                          (narrow-to-region (point-min) hdr-end)
+                          (goto-char (point-min))
+                          (mail-header-extract))))
+             (body-start (save-excursion
+                           (goto-char hdr-end)
+                           (forward-line 1)
+                           (point))))
+        (cons headers
+              (buffer-substring-no-properties body-start (point-max)))))))
+
+(defun patch-review--decode (s)
+  "Decode encoded words in header string S, if possible."
+  (when s
+    (if (fboundp 'mail-decode-encoded-word-string)
+        (mail-decode-encoded-word-string s)
+      s)))
+
+;;;; Header line
+
+(defun patch-review--button (label help command)
+  "Return LABEL as a clickable header-line button running COMMAND."
+  (propertize label
+              'keymap (let ((map (make-sparse-keymap)))
+                        (define-key map [header-line mouse-1] command)
+                        map)
+              'mouse-face 'highlight
+              'follow-link t
+              'help-echo help))
+
+(defun patch-review--status-string ()
+  "Propertized status text for the header line."
+  (pcase patch-review--status
+    ('applicable (propertize "applies cleanly" 'face 'patch-review-status-ok))
+    ('applied (propertize "already applied" 'face 'patch-review-status-ok))
+    ('dirty (propertize "worktree dirty" 'face 'patch-review-status-warn))
+    ('conflict (propertize "conflicts" 'face 'patch-review-status-error))
+    ('am-in-progress (propertize "am in progress" 'face 'patch-review-status-error))
+    ('no-project (propertize "no project (C-c C-p)" 'face 'patch-review-status-warn))
+    ('checking (propertize "checking..." 'face 'shadow))
+    (_ (propertize "unknown" 'face 'shadow))))
+
+(defun patch-review--header-line ()
+  "Compute the review buffer's header line."
+  (let ((subject (or (patch-review--decode (cdr (assq 'subject
+                                                      patch-review--headers)))
+                     (buffer-name)))
+        (project (if patch-review--project
+                     (abbreviate-file-name
+                      (directory-file-name patch-review--project))
+                   "—")))
+    (concat
+     " " (propertize subject 'face 'bold)
+     "  |  Project: " project
+     "  |  " (propertize (substring-no-properties (patch-review--status-string))
+                       'face (get-text-property
+                              0 'face (patch-review--status-string))
+                       'help-echo patch-review--status-detail)
+     "  |  "
+     (patch-review--button "[Send review]" "C-c C-c: compose the review reply"
+                           #'patch-review-send-review)
+     " "
+     (patch-review--button "[Apply]" "C-c C-a: apply with git am"
+                           #'patch-review-apply)
+     " "
+     (patch-review--button "[Refresh]" "C-c C-r: re-run the applicability probe"
+                           #'patch-review-refresh-status))))
+
+;;;; Project and status
+
+(defun patch-review-set-project (dir)
+  "Set the repository DIR this patch targets and re-run the probe."
+  (interactive "DProject repository: " patch-review-mode)
+  (unless (patch-review-git-describe dir)
+    (user-error "%s is not a git working tree" dir))
+  (setq patch-review--project dir)
+  (patch-review-refresh-status))
+
+(defun patch-review-refresh-status ()
+  "Re-run the applicability probe for the current project."
+  (interactive nil patch-review-mode)
+  (cond
+   ((null patch-review--project)
+    (setq patch-review--status 'no-project
+          patch-review--status-detail "Set a project with C-c C-p."))
+   ((null patch-review--source-file)
+    (setq patch-review--status 'unknown
+          patch-review--status-detail "No source file to probe."))
+   (t
+    (setq patch-review--status 'checking)
+    (force-mode-line-update t)
+    (message "Probing %s..." patch-review--project)
+    (pcase-let ((`(,status . ,detail)
+                 (patch-review-git-check patch-review--project
+                                         patch-review--source-file)))
+      (setq patch-review--status status
+            patch-review--status-detail detail)
+      (message "Status: %s" (substring-no-properties
+                             (patch-review--status-string))))))
+  (force-mode-line-update t))
+
+;;;; Comment extraction
+
+(defun patch-review--write-temp (text)
+  "Write TEXT to a temporary file (final newline ensured); return it."
+  (let ((file (make-temp-file "patch-review")))
+    (with-temp-file file
+      (insert text)
+      (unless (or (zerop (buffer-size))
+                  (eq (char-before (point-max)) ?\n))
+        (goto-char (point-max))
+        (insert "\n")))
+    file))
+
+(defun patch-review--locator-index (parsed)
+  "Map body-line indices of PARSED to locator strings."
+  (let ((index (make-hash-table :test #'eql)))
+    (cl-loop for fi from 0
+             for file in (patch-review-email-files parsed)
+             do (cl-loop for hi from 0
+                         for hunk in (patch-review-file-hunks file)
+                         do (cl-loop
+                             for li from 0
+                             for line in (patch-review-hunk-lines hunk)
+                             do (puthash (patch-review-line-body-line line)
+                                         (patch-review-reply-locator fi hi li)
+                                         index))))
+    index))
+
+(defun patch-review--add-comment (comments anchor added locator-index)
+  "Record ADDED lines as a comment at ANCHOR (1-based pristine line).
+ANCHOR 0 or an anchor outside any hunk maps to the general comment.
+Return the updated COMMENTS alist."
+  (let ((text (string-trim (mapconcat #'identity added "\n"))))
+    (if (string-empty-p text)
+        comments
+      (let* ((locator (or (and (> anchor 0)
+                               (gethash (1- anchor) locator-index))
+                          patch-review-reply-general))
+             (existing (assoc locator comments)))
+        (if existing
+            (progn
+              (setcdr existing (concat (cdr existing) "\n\n" text))
+              comments)
+          (cons (cons locator text) comments))))))
+
+(defun patch-review--parse-diff (text locator-index)
+  "Parse `diff -U0' output TEXT against LOCATOR-INDEX.
+Return (COMMENTS DELETIONS CHANGES): an alist (LOCATOR . TEXT) and
+counts of original lines the user deleted or rewrote."
+  (let ((comments nil) (deletions 0) (changes 0))
+    (with-temp-buffer
+      (insert text)
+      (goto-char (point-min))
+      (while (re-search-forward
+              "^@@ -\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? \\+\\([0-9]+\\)\\(?:,\\([0-9]+\\)\\)? @@"
+              nil t)
+        (let ((old-start (string-to-number (match-string 1)))
+              (old-count (if (match-string 2)
+                             (string-to-number (match-string 2)) 1))
+              (new-count (if (match-string 4)
+                             (string-to-number (match-string 4)) 1))
+              (added nil) (removed nil))
+          (forward-line 1)
+          (while (and (not (eobp)) (looking-at "^[-+\\\\]"))
+            (pcase (char-after)
+              (?+ (push (buffer-substring-no-properties
+                         (1+ (point)) (line-end-position))
+                        added))
+              (?- (push (buffer-substring-no-properties
+                         (1+ (point)) (line-end-position))
+                        removed)))
+            (forward-line 1))
+          (setq added (nreverse added)
+                removed (nreverse removed))
+          (cond
+           ;; Pure insertion: comment on the original line above.
+           ((= old-count 0)
+            (setq comments
+                  (patch-review--add-comment comments old-start added
+                                             locator-index)))
+           ;; Pure deletion of original text.
+           ((= new-count 0)
+            (setq deletions (+ deletions (length removed))))
+           ;; Rewritten lines: the new text is a comment on the last
+           ;; replaced line — unless only the final newline changed,
+           ;; which diff reports as remove+add of identical lines.
+           ((not (equal added removed))
+            (setq changes (+ changes (length removed))
+                  comments
+                  (patch-review--add-comment
+                   comments (+ old-start old-count -1) added
+                   locator-index)))))))
+    (list comments deletions changes)))
+
+(defun patch-review-extract-comments (parsed pristine current)
+  "Diff PRISTINE against CURRENT and extract the user's comments.
+PARSED is the `patch-review-email' struct for PRISTINE.  Return
+\(COMMENTS DELETIONS CHANGES); see `patch-review--parse-diff'."
+  (let ((a (patch-review--write-temp pristine))
+        (b (patch-review--write-temp current)))
+    (unwind-protect
+        (let ((output
+               (with-temp-buffer
+                 (let ((exit (call-process "diff" nil t nil "-U0"
+                                           "--label" "pristine"
+                                           "--label" "current" a b)))
+                   (pcase exit
+                     (0 nil)
+                     (1 (buffer-string))
+                     (_ (error "diff failed: %s" (buffer-string))))))))
+          (if (null output)
+              (list nil 0 0)
+            (patch-review--parse-diff
+             output (patch-review--locator-index parsed))))
+      (delete-file a)
+      (delete-file b))))
+
+;;;; Sending the review
+
+(defun patch-review-send-review ()
+  "Extract comments from the buffer and compose the review reply.
+Text inserted above the first hunk becomes a general remark; text
+inserted anywhere else is a comment on the patch line above it."
+  (interactive nil patch-review-mode)
+  (unless patch-review--parsed
+    (user-error "Not a patch review buffer"))
+  (pcase-let ((`(,comments ,deletions ,changes)
+               (patch-review-extract-comments
+                patch-review--parsed
+                patch-review--pristine
+                (buffer-substring-no-properties (point-min) (point-max)))))
+    (when (or (> deletions 0) (> changes 0))
+      (unless (y-or-n-p
+               (format "Your edits rewrote or deleted %d patch line%s (quoted from the original).  Send anyway? "
+                       (+ deletions changes)
+                       (if (= 1 (+ deletions changes)) "" "s")))
+        (user-error "Aborted")))
+    (unless comments
+      (when (y-or-n-p "No inline comments found; add a general comment? ")
+        (let ((text (read-string "General comment: ")))
+          (unless (string-empty-p (string-trim text))
+            (push (cons patch-review-reply-general text) comments))))
+      (unless comments
+        (user-error "Nothing to send")))
+    (patch-review--compose
+     (patch-review-reply-format patch-review--parsed comments))))
+
+(defun patch-review--compose (body)
+  "Open a `message-mode' reply buffer containing BODY."
+  (let* ((headers patch-review--headers)
+         (from (patch-review--decode (cdr (assq 'from headers))))
+         (to (or from (read-string "To: ")))
+         (subject (or (cdr (assq 'subject headers)) "patch review"))
+         (message-id (cdr (assq 'message-id headers)))
+         (cc (patch-review--cc-list headers))
+         (new-subject
+          (concat "Re: "
+                  (replace-regexp-in-string
+                   "^\\s-*\\(?:[Rr][Ee]\\s-*:\\s-*\\)*" "" subject)))
+         (other-headers
+          (append (unless (string-empty-p cc) `(("Cc" . ,cc)))
+                  (when message-id
+                    `(("In-Reply-To" . ,message-id)
+                      ("References" . ,message-id))))))
+    (message-mail to new-subject other-headers)
+    (message-goto-body)
+    (delete-region (point) (point-max))
+    (insert body)
+    (message-goto-body)
+    (message "Review the draft, then C-c C-c to send.")))
+
+(defun patch-review--cc-list (headers)
+  "Join the original To and Cc for a wide reply."
+  (mapconcat #'identity
+             (delq nil (mapcar (lambda (s) (and s (string-trim s)))
+                               (list (cdr (assq 'to headers))
+                                     (cdr (assq 'cc headers)))))
+             ", "))
+
+;;;; Applying
+
+(defun patch-review-apply (&optional no-hooks)
+  "Apply the reviewed patch to the current project with `git am'.
+With prefix argument NO-HOOKS, suppress the user's git hooks."
+  (interactive "P" patch-review-mode)
+  (unless patch-review--project
+    (user-error "No project set; use C-c C-p first"))
+  (unless patch-review--source-file
+    (user-error "No patch file to apply"))
+  (pcase-let ((`(,status . ,output)
+               (patch-review-git-apply patch-review--project
+                                       patch-review--source-file
+                                       no-hooks)))
+    (setq patch-review--status status
+          patch-review--status-detail output)
+    (force-mode-line-update t)
+    (pcase status
+      ('applied
+       (message "Applied to %s." (abbreviate-file-name
+                                  (directory-file-name
+                                   patch-review--project))))
+      (_
+       (with-current-buffer (get-buffer-create "*patch-review-apply*")
+         (let ((inhibit-read-only t))
+           (erase-buffer)
+           (insert (format "git am in %s — %s\n\n%s"
+                           (abbreviate-file-name
+                            (directory-file-name patch-review--project))
+                           (substring-no-properties
+                            (patch-review--status-string))
+                           output))
+           (goto-char (point-min))
+           (diff-mode)
+           (read-only-mode 1))
+         (pop-to-buffer (current-buffer)))))))
+
+;;;###autoload
+(add-to-list 'auto-mode-alist '("\\.\\(?:patch\\|diff\\)\\'" . patch-review-mode))
+
+(provide 'patch-review)
+;;; patch-review.el ends here
diff --git a/tests/patch-review-test.el b/tests/patch-review-test.el
@@ -4,6 +4,7 @@
 (require 'patch-review-parse)
 (require 'patch-review-reply)
 (require 'patch-review-git)
+(require 'patch-review)
 
 (defun patch-review-test-fixture (name)
   "Return the contents of fixture NAME."
@@ -296,5 +297,190 @@ Return the path of a format-patch file for the patch commit."
       (delete-file a)
       (delete-file b))))
 
+;;;; Comment extraction
+
+(defun patch-review-test--insert-after-line (text prefix insertion)
+  "Return TEXT with INSERTION added on a new line after the first line
+starting with PREFIX."
+  (let ((lines (split-string text "\n")) (out nil) (done nil))
+    (dolist (l lines)
+      (push l out)
+      (when (and (not done) (string-prefix-p prefix l))
+        (push insertion out)
+        (setq done t)))
+    (unless done (error "No line starting with %S" prefix))
+    (mapconcat #'identity (nreverse out) "\n")))
+
+(defun patch-review-test--remove-line (text prefix)
+  "Return TEXT without its first line starting with PREFIX."
+  (let ((lines (split-string text "\n")) (out nil) (done nil))
+    (dolist (l lines)
+      (if (and (not done) (string-prefix-p prefix l))
+          (setq done t)
+        (push l out)))
+    (unless done (error "No line starting with %S" prefix))
+    (mapconcat #'identity (nreverse out) "\n")))
+
+(defun patch-review-test--extract (parsed pristine current)
+  (patch-review-extract-comments parsed pristine current))
+
+(ert-deftest patch-review-test-extract-no-edits ()
+  (let* ((body (patch-review-test-fixture "patch1.body"))
+         (parsed (patch-review-parse-email body)))
+    (should (equal '(nil 0 0)
+                   (patch-review-test--extract parsed body body)))))
+
+(ert-deftest patch-review-test-extract-general ()
+  (let* ((body (patch-review-test-fixture "patch1.body"))
+         (parsed (patch-review-parse-email body))
+         (edited (concat "Ship it.\n" body)))
+    (pcase-let ((`(,comments ,del ,chg)
+                 (patch-review-test--extract parsed body edited)))
+      (should (equal '(0 0) (list del chg)))
+      (should (equal "Ship it."
+                     (cdr (assoc patch-review-reply-general comments)))))))
+
+(ert-deftest patch-review-test-extract-hunk-comment ()
+  (let* ((body (patch-review-test-fixture "patch1.body"))
+         (parsed (patch-review-parse-email body))
+         (edited (patch-review-test--insert-after-line
+                  body "+#include <errno.h>" "Is this portable?"))
+         (hunk (car (patch-review-file-hunks
+                     (car (patch-review-email-files parsed)))))
+         (target (cl-position-if
+                  (lambda (l) (equal "#include <errno.h>"
+                                     (patch-review-line-text l)))
+                  (patch-review-hunk-lines hunk))))
+    (pcase-let ((`(,comments ,del ,chg)
+                 (patch-review-test--extract parsed body edited)))
+      (should (equal '(0 0) (list del chg)))
+      (should (equal "Is this portable?"
+                     (cdr (assoc (patch-review-reply-locator 0 0 target)
+                                 comments)))))))
+
+(ert-deftest patch-review-test-extract-diffstat-is-general ()
+  (let* ((body (patch-review-test-fixture "patch1.body"))
+         (parsed (patch-review-parse-email body))
+         (edited (patch-review-test--insert-after-line
+                  body " src/socket.c |" "Nice diffstat.")))
+    (pcase-let ((`(,comments ,_ ,_)
+                 (patch-review-test--extract parsed body edited)))
+      (should (equal "Nice diffstat."
+                     (cdr (assoc patch-review-reply-general comments)))))))
+
+(ert-deftest patch-review-test-extract-deletion ()
+  (let* ((body (patch-review-test-fixture "patch1.body"))
+         (parsed (patch-review-parse-email body))
+         (edited (patch-review-test--remove-line body " \tprintf(\"opening")))
+    (pcase-let ((`(,comments ,del ,chg)
+                 (patch-review-test--extract parsed body edited)))
+      (should (null comments))
+      (should (equal '(1 0) (list del chg))))))
+
+(ert-deftest patch-review-test-extract-change-is-comment ()
+  (let* ((body (patch-review-test-fixture "patch1.body"))
+         (parsed (patch-review-parse-email body))
+         (edited (patch-review-test--insert-after-line
+                  (patch-review-test--remove-line body "+#include <errno.h>")
+                  " #include <stdio.h>" "Why not <errno.h>?")))
+    (pcase-let ((`(,comments ,del ,chg)
+                 (patch-review-test--extract parsed body edited)))
+      ;; One context line was not rewritten; the removed + line counts
+      ;; as a change and the new text is a comment.
+      (should (= 1 chg))
+      (should (= 0 del))
+      (should (cl-some (lambda (e) (string-match-p "Why not" (cdr e)))
+                       comments)))))
+
+(ert-deftest patch-review-test-extract-trailing-newline-deletion ()
+  ;; patch1.body ends with "2.55.0\n\n": dropping one trailing newline
+  ;; deletes the (empty) final line and is reported as a deletion.
+  (let* ((body (patch-review-test-fixture "patch1.body"))
+         (parsed (patch-review-parse-email body))
+         (edited (string-remove-suffix "\n" body)))
+    (should (equal '(nil 1 0)
+                   (patch-review-test--extract parsed body edited)))))
+
+(ert-deftest patch-review-test-extract-final-newline-normalized ()
+  ;; A missing final newline in the edited buffer is normalized away
+  ;; before diffing: it produces neither comments nor deletion counts.
+  (let* ((parsed (patch-review-parse-email "l1\nl2\n"))
+         (pristine "l1\nl2\n")
+         (edited "l1\nl2"))
+    (should (equal '(nil 0 0)
+                   (patch-review-test--extract parsed pristine edited)))))
+
+;;;; End-to-end: open, comment, send
+
+(ert-deftest patch-review-test-open-and-send ()
+  (let* ((eml (expand-file-name
+               "fixtures/patch1.eml"
+               (file-name-directory (or load-file-name buffer-file-name))))
+         (buf (patch-review-open-message-file eml)))
+    (unwind-protect
+        (with-current-buffer buf
+          (should (eq major-mode 'patch-review-mode))
+          (should (eq (lookup-key patch-review-mode-map (kbd "C-c C-c"))
+                      #'patch-review-send-review))
+          (goto-char (point-min))
+          (re-search-forward "^\+#include <errno.h>$")
+          (end-of-line)
+          (insert "\nIs this portable?")
+          (patch-review-send-review)
+          ;; Now in the message-mode draft.
+          (let ((mail (buffer-string)))
+            (should (string-match-p
+                     "^To: Aisha Developer <aisha@example.org>$" mail))
+            (should (string-match-p
+                     (concat "^" (regexp-quote
+                                  "Subject: Re: [PATCH 1/3] Reject negative"))
+                     mail))
+            (should (string-match-p
+                     "^In-Reply-To: <patch1@example.org>$" mail))
+            (should (string-match-p
+                     (regexp-quote "> +#include <errno.h>") mail))
+            (should (string-match-p
+                     (concat "^" (regexp-quote "Is this portable?") "$")
+                     mail))
+            (kill-buffer)))
+      (when (buffer-live-p buf) (kill-buffer buf)))))
+
+(ert-deftest patch-review-test-send-without-comments-aborts ()
+  (let* ((eml (expand-file-name
+               "fixtures/patch1.eml"
+               (file-name-directory (or load-file-name buffer-file-name))))
+         (buf (patch-review-open-message-file eml)))
+    (unwind-protect
+        (with-current-buffer buf
+          (cl-letf (((symbol-function #'y-or-n-p) (lambda (_) nil)))
+            (should-error (patch-review-send-review) :type 'user-error)))
+      (when (buffer-live-p buf) (kill-buffer buf)))))
+
+;;;; End-to-end: status probe and apply
+
+(ert-deftest patch-review-test-status-and-apply ()
+  (patch-review-test--with-repo dir
+    (let* ((mbox (patch-review-test--make-patch dir))
+           (text (with-temp-buffer
+                   (insert-file-contents mbox)
+                   (buffer-string)))
+           (buf (patch-review--open-text text mbox dir)))
+      (unwind-protect
+          (with-current-buffer buf
+            (should (eq 'applicable patch-review--status))
+            (should (string-match-p "applies cleanly"
+                                    (patch-review--header-line)))
+            (patch-review-apply)
+            (should (eq 'applied patch-review--status))
+            (should (equal patch-review-test--new-content
+                           (with-temp-buffer
+                             (insert-file-contents
+                              (expand-file-name "src/socket.c" dir))
+                             (buffer-string))))
+            ;; And the probe agrees afterwards.
+            (patch-review-refresh-status)
+            (should (eq 'applied patch-review--status)))
+        (when (buffer-live-p buf) (kill-buffer buf))))))
+
 (provide 'patch-review-test)
 ;;; patch-review-test.el ends here