emacs-patch-review
Port of Thunderbird Patch Review to mu4e.
git clone git://mccd.space/emacs-patch-reviewcommit 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:
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