aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pearl.el62
-rw-r--r--tests/test-pearl-comment-editing.el52
2 files changed, 87 insertions, 27 deletions
diff --git a/pearl.el b/pearl.el
index 18c65c2..3ade6dc 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1762,6 +1762,7 @@ A null author renders as `(unknown)'."
(format ":LINEAR-COMMENT-ID: %s\n" (or (plist-get comment :id) ""))
(format ":LINEAR-COMMENT-AUTHOR-ID: %s\n" (or (plist-get comment :author-id) ""))
(format ":LINEAR-COMMENT-SHA256: %s\n" (secure-hash 'sha256 raw-body))
+ (format ":LINEAR-COMMENT-ORG-SHA256: %s\n" (secure-hash 'sha256 (string-trim body)))
":END:\n"
(if (string-empty-p body) "" (concat body "\n")))))
@@ -3283,28 +3284,37 @@ track correctly across the in-place inserts and deletes the merge performs."
;;; Dirty detection (the save model's local scanners)
+(defun pearl--comment-dirty-p ()
+ "Return non-nil when the comment body at point differs from its last fetch.
+An Org-to-Org comparison against `LINEAR-COMMENT-ORG-SHA256' (the rendered body
+as fetched), so a comment whose markdown does not survive md->org->md (a heading
+or single-asterisk emphasis) is not mistaken for a local edit -- the same fix
+`pearl--subtree-dirty-p' applies to descriptions. Comments rendered before that
+baseline existed fall back to the older markdown-round-trip comparison against
+`LINEAR-COMMENT-SHA256'; they migrate on the next re-render."
+ (let ((org-hash (org-entry-get nil "LINEAR-COMMENT-ORG-SHA256")))
+ (if org-hash
+ (not (string= (secure-hash 'sha256 (pearl--entry-body-at-point)) org-hash))
+ (let ((stored (or (org-entry-get nil "LINEAR-COMMENT-SHA256") "")))
+ (not (string= (secure-hash 'sha256 (pearl--org-to-md (pearl--entry-body-at-point)))
+ stored))))))
+
(defun pearl--changed-comment-candidates ()
"Return comments under the issue at point whose body changed since fetch.
-Each is a plist (:comment-id :author-id :marker). Local only: compares
-`secure-hash' of `pearl--org-to-md' of each comment body to its stored
-`LINEAR-COMMENT-SHA256' (taken over the markdown Linear stored, so the
-comparison must round-trip through markdown, as `pearl-edit-current-comment'
-does). A candidate only means the body differs; ownership is decided later."
+Each is a plist (:comment-id :author-id :marker). Local only, via
+`pearl--comment-dirty-p'. A candidate only means the body differs; ownership is
+decided later."
(let (candidates)
(save-excursion
(pearl--goto-heading-or-error)
(let ((issue-end (save-excursion (org-end-of-subtree t t) (point))))
(while (and (outline-next-heading) (< (point) issue-end))
(let ((cid (org-entry-get nil "LINEAR-COMMENT-ID")))
- (when (and cid (not (string-empty-p cid)))
- (let ((stored (or (org-entry-get nil "LINEAR-COMMENT-SHA256") ""))
- (local (secure-hash 'sha256
- (pearl--org-to-md (pearl--entry-body-at-point)))))
- (unless (string= local stored)
- (push (list :comment-id cid
- :author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID")
- :marker (point-marker))
- candidates))))))))
+ (when (and cid (not (string-empty-p cid)) (pearl--comment-dirty-p))
+ (push (list :comment-id cid
+ :author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID")
+ :marker (point-marker))
+ candidates))))))
(nreverse candidates)))
(defun pearl--priority-number-at-point ()
@@ -4728,23 +4738,21 @@ reported (refresh to reconcile)."
(save-excursion
(pearl--goto-heading-or-error "Not on a Linear comment")
(let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID"))
- (stored (org-entry-get nil "LINEAR-COMMENT-SHA256"))
(marker (point-marker))
(buf (current-buffer)))
(unless comment-id
(user-error "Not on a Linear comment"))
- ;; No-op fast path before resolving the viewer: an unchanged comment
- ;; costs no viewer lookup. `pearl--entry-body-at-point' reads the text
- ;; after the drawer and before the first child heading -- a comment
- ;; subtree has that shape, so it serves the comment body unchanged.
- (let ((local-md (pearl--org-to-md (pearl--entry-body-at-point))))
- (if (string= (secure-hash 'sha256 local-md) (or stored ""))
- (message "No comment changes to sync")
- (pearl--viewer-async
- (lambda (viewer)
- (pearl--save-comment-field
- marker (and viewer (plist-get viewer :id))
- (lambda (outcome) (pearl--report-save-outcome outcome buf))))))))))
+ ;; No-op fast path before resolving the viewer: an unchanged comment costs
+ ;; no viewer lookup. The dirty check is Org-to-Org (see
+ ;; `pearl--comment-dirty-p'), so a comment whose markdown does not survive
+ ;; the round-trip is not falsely seen as edited.
+ (if (not (pearl--comment-dirty-p))
+ (message "No comment changes to sync")
+ (pearl--viewer-async
+ (lambda (viewer)
+ (pearl--save-comment-field
+ marker (and viewer (plist-get viewer :id))
+ (lambda (outcome) (pearl--report-save-outcome outcome buf)))))))))
;;;###autoload
(defun pearl-delete-current-comment ()
diff --git a/tests/test-pearl-comment-editing.el b/tests/test-pearl-comment-editing.el
index 32b194d..4853548 100644
--- a/tests/test-pearl-comment-editing.el
+++ b/tests/test-pearl-comment-editing.el
@@ -365,5 +365,57 @@ adds the comment there rather than refusing."
(re-search-forward "see :note:")
(should (string= "see :note: below\nand more text" (pearl--entry-body-at-point)))))
+;;; round-trip stability of the dirty check (the read-only-every-save fix)
+
+(defun test-pearl-comment-editing--commented-issue (body)
+ "An issue subtree whose one comment (by another user) has BODY."
+ (concat "** TODO ENG-1: x\n:PROPERTIES:\n:LINEAR-ID: i\n:END:\nissue body\n"
+ (pearl--format-comment
+ (list :id "c1" :author "Eric" :author-id "u-eric"
+ :created-at "2026-01-01T00:00:00.000Z" :body body))))
+
+(ert-deftest test-pearl-comment-dirty-p-stable-for-lossy-markdown ()
+ "A freshly rendered comment whose markdown does not survive md->org->md
+(a heading, single-asterisk italics) is not seen as dirty."
+ (test-pearl--in-org (test-pearl-comment-editing--commented-issue
+ "# Heading\n\nsome *italic* words")
+ (re-search-forward "LINEAR-COMMENT-ID")
+ (org-back-to-heading t)
+ (should-not (pearl--comment-dirty-p))))
+
+(ert-deftest test-pearl-comment-dirty-p-detects-a-real-edit ()
+ "An actual edit to the comment body is detected as dirty."
+ (test-pearl--in-org (test-pearl-comment-editing--commented-issue "original text")
+ (re-search-forward "LINEAR-COMMENT-ID")
+ (org-back-to-heading t)
+ (should-not (pearl--comment-dirty-p))
+ (goto-char (point-max))
+ (insert " EDITED")
+ (org-back-to-heading t)
+ (should (pearl--comment-dirty-p))))
+
+(ert-deftest test-pearl-changed-comment-candidates-ignores-unedited-lossy-comment ()
+ "The save scan does not flag an unedited comment with lossy markdown, so a
+non-viewer comment is no longer reported read-only on every save."
+ (test-pearl--in-org (test-pearl-comment-editing--commented-issue
+ "## A heading\n\n*emphasis* and text")
+ (re-search-forward "^\\*\\* ")
+ (org-back-to-heading t)
+ (should-not (pearl--changed-comment-candidates))))
+
+(ert-deftest test-pearl-comment-dirty-p-legacy-falls-back-to-md-hash ()
+ "A comment rendered before the Org baseline existed (no LINEAR-COMMENT-ORG-SHA256)
+falls back to the markdown-hash comparison, so a clean legacy comment is not dirty."
+ (test-pearl--in-org
+ (concat "** TODO ENG-1: x\n:PROPERTIES:\n:LINEAR-ID: i\n:END:\n"
+ "**** Eric — 2026-01-01T00:00:00.000Z\n:PROPERTIES:\n"
+ ":LINEAR-COMMENT-ID: c1\n"
+ ":LINEAR-COMMENT-AUTHOR-ID: u-eric\n"
+ (format ":LINEAR-COMMENT-SHA256: %s\n" (secure-hash 'sha256 "plain body"))
+ ":END:\nplain body\n")
+ (re-search-forward "LINEAR-COMMENT-ID")
+ (org-back-to-heading t)
+ (should-not (pearl--comment-dirty-p))))
+
(provide 'test-pearl-comment-editing)
;;; test-pearl-comment-editing.el ends here