emacs-patch-review

Port of Thunderbird Patch Review to mu4e.

git clone git://mccd.space/emacs-patch-review
commit 6df1b7cd983a55858094875451578a238634c0a3
parent e191b62fd2d344b7b37769970983b4af25a7ca29
Author: Pi Agent <agent@pi.local>
Date:   Mon,  3 Aug 2026 09:52:59 +0200

Add patch-review-reply: interleaved reply formatting

Port of reply-format.js plus the locator scheme from review-store.js:
comments keyed by "file:hunk:line" (or "general") are rendered as a
kernel-style quoted reply, three lines of context above the first
commented line and "> [...]" elision beyond that.

Diffstat:
Apatch-review-reply.el | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/patch-review-test.el | 50++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 157 insertions(+), 0 deletions(-)
diff --git a/patch-review-reply.el b/patch-review-reply.el
@@ -0,0 +1,107 @@
+;;; patch-review-reply.el --- Format review replies -*- 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.
+
+;;; Commentary:
+
+;; Turn draft comments into a plain-text, interleaved mailing-list
+;; review: the commented regions of the patch are quoted with "> " and
+;; each comment sits unquoted below the line it targets — kernel-style.
+;;
+;; Port of thunderbird-review-ui's modules/reply-format.js and the
+;; locator scheme of modules/review-store.js.  Pure, no side effects.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'patch-review-parse)
+
+(defconst patch-review-reply-general "general"
+  "Locator key for the general (whole-patch) comment.")
+
+(defconst patch-review-reply-context-before 3
+  "Quoted lines shown above the first comment in a hunk.")
+
+(defun patch-review-reply-locator (file-index hunk-index line-index)
+  "Build a locator string for FILE-INDEX, HUNK-INDEX and LINE-INDEX."
+  (format "%d:%d:%d" file-index hunk-index line-index))
+
+(defun patch-review-reply-parse-locator (locator)
+  "Parse LOCATOR into (FILE-INDEX HUNK-INDEX LINE-INDEX)."
+  (mapcar #'string-to-number (split-string locator ":")))
+
+(defun patch-review-reply-format (parsed comments)
+  "Format COMMENTS against PARSED (a `patch-review-email' struct).
+COMMENTS is an alist (LOCATOR . TEXT); LOCATOR is
+`patch-review-reply-general' or a \"file:hunk:line\" string.
+Return the reply body, or \"\" when there are no comments."
+  (if (null comments)
+      ""
+    (let ((out nil)
+          (by-hunk (make-hash-table :test #'equal))
+          (general (assoc patch-review-reply-general comments)))
+      (when general
+        (push (cdr general) out)
+        (push "" out))
+      ;; Group line comments by file/hunk.
+      (dolist (entry comments)
+        (unless (equal (car entry) patch-review-reply-general)
+          (pcase-let ((`(,f ,h ,l) (patch-review-reply-parse-locator (car entry))))
+            (push (list f h l (cdr entry))
+                  (gethash (format "%d:%d" f h) by-hunk)))))
+      (let ((groups nil))
+        (maphash (lambda (_k v) (push v groups)) by-hunk)
+        (setq groups
+              (sort groups
+                    (lambda (a b)
+                      (let ((fa (car a)) (fb (car b)))
+                        (if (/= fa fb)
+                            (< fa fb)
+                          (< (cadr a) (cadr b)))))))
+        (let ((last-file -1))
+          (dolist (group groups)
+            (setq group (sort group (lambda (a b) (< (caddr a) (caddr b)))))
+            (pcase-let ((`(,file-index ,hunk-index ,_ ,_) (car group)))
+              (let* ((file (nth file-index (patch-review-email-files parsed)))
+                     (hunk (nth hunk-index (patch-review-file-hunks file))))
+                (when hunk
+                  (when (/= file-index last-file)
+                    (push (format "> diff --git a/%s b/%s"
+                                  (patch-review-file-old-path file)
+                                  (patch-review-file-new-path file))
+                          out)
+                    (setq last-file file-index))
+                  (push (concat "> " (patch-review-hunk-header hunk)) out)
+                  ;; Quote from a little context above the first commented
+                  ;; line through the last commented line, inserting
+                  ;; comments after their targets.
+                  (let* ((lines (patch-review-hunk-lines hunk))
+                         (first (caddr (car group)))
+                         (last (caddr (car (last group))))
+                         (start (max 0 (- first patch-review-reply-context-before)))
+                         (pending group))
+                    (when (> start 0)
+                      (push "> [...]" out))
+                    (cl-loop for i from start
+                             while (and (<= i last) (< i (length lines)))
+                             do (push (concat "> " (patch-review-line-raw
+                                                    (nth i lines)))
+                                      out)
+                             (while (and pending (= (caddr (car pending)) i))
+                               (push "" out)
+                               (push (nth 3 (car pending)) out)
+                               (push "" out)
+                               (setq pending (cdr pending))))
+                    (push "" out))))))))
+      ;; `out' is reversed: insertion-order trailing blanks lead the list.
+      (while (and out (equal (car out) ""))
+        (setq out (cdr out)))
+      (concat (mapconcat #'identity (nreverse out) "\n") "\n"))))
+
+(provide 'patch-review-reply)
+;;; patch-review-reply.el ends here
diff --git a/tests/patch-review-test.el b/tests/patch-review-test.el
@@ -2,6 +2,7 @@
 
 (require 'ert)
 (require 'patch-review-parse)
+(require 'patch-review-reply)
 
 (defun patch-review-test-fixture (name)
   "Return the contents of fixture NAME."
@@ -116,5 +117,54 @@
                (patch-review-parse-subject "[PATCH 1/3] a")
                (patch-review-parse-subject "[PATCH 1/4] b"))))
 
+;;;; Reply formatting (ports of the reply-format.js checks)
+
+(ert-deftest patch-review-test-reply-format ()
+  (let* ((p (patch-review-parse-email (patch-review-test-fixture "patch1.body")))
+         (hunk (car (patch-review-file-hunks
+                     (car (patch-review-email-files p)))))
+         (lines (patch-review-hunk-lines hunk))
+         (target (cl-position-if
+                  (lambda (l) (equal "#include <errno.h>"
+                                     (patch-review-line-text l)))
+                  lines))
+         (later (cl-position-if
+                 (lambda (l) (string-match-p "errno = EBADF"
+                                             (patch-review-line-text l)))
+                 lines))
+         (comments
+          (list (cons patch-review-reply-general "Looks good overall, two nits.")
+                (cons (patch-review-reply-locator 0 0 target)
+                      "Is errno.h needed on all platforms?")
+                (cons (patch-review-reply-locator 0 0 later)
+                      "EBADF or EINVAL here?")))
+         (reply (patch-review-reply-format p comments)))
+    (should (string-prefix-p "Looks good overall" reply))
+    (should (string-match-p
+             (regexp-quote "> diff --git a/src/socket.c b/src/socket.c") reply))
+    (should (string-match-p
+             (regexp-quote (concat "> " (patch-review-hunk-header hunk))) reply))
+    (let ((quoted (string-match (regexp-quote "> +#include <errno.h>") reply))
+          (comment (string-match (regexp-quote "Is errno.h needed") reply))
+          (second (string-match (regexp-quote "EBADF or EINVAL") reply)))
+      (should quoted)
+      (should (< quoted comment))
+      (should (< comment second)))))
+
+(ert-deftest patch-review-test-reply-elision ()
+  ;; A comment deep into a hunk elides the unquoted head with "> [...]".
+  (let* ((p (patch-review-parse-email (patch-review-test-fixture "patch1.body")))
+         (hunk (car (patch-review-file-hunks
+                     (car (patch-review-email-files p)))))
+         (last-idx (1- (length (patch-review-hunk-lines hunk))))
+         (reply (patch-review-reply-format
+                 p (list (cons (patch-review-reply-locator 0 0 last-idx)
+                               "tail comment")))))
+    (should (string-match-p (regexp-quote "> [...]") reply))
+    (should (string-suffix-p "tail comment\n" reply))))
+
+(ert-deftest patch-review-test-reply-empty ()
+  (should (equal "" (patch-review-reply-format nil nil))))
+
 (provide 'patch-review-test)
 ;;; patch-review-test.el ends here