diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-27 16:52:07 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-27 16:52:07 -0500 |
| commit | 34b4e5ecd8468129cb97e870987fc8bb8d27d6e2 (patch) | |
| tree | 6f6cc33ab635f6f541857ea38b817ad0688a4f25 | |
| parent | 78bd607f8dd1f4c29e4d7dda9744b06bf7352105 (diff) | |
| download | pearl-34b4e5ecd8468129cb97e870987fc8bb8d27d6e2.tar.gz pearl-34b4e5ecd8468129cb97e870987fc8bb8d27d6e2.zip | |
fix(save): detect comment edits with a rendered-Org hash, not the md round-trip
A comment whose markdown doesn't survive md->org->md (a heading, single-asterisk italics) was flagged changed on every save, because the dirty scan compared org-to-md of the rendered body against the stored markdown hash. When the comment was authored by someone else, that surfaced as "1 read-only comment skipped" on every save even though nothing had been edited.
Descriptions already dodge this: 9424b84 gave them LINEAR-DESC-ORG-SHA256 and compared Org-to-Org. I did the same for comments. pearl--format-comment now writes LINEAR-COMMENT-ORG-SHA256 (the rendered body hash), and a new pearl--comment-dirty-p compares the current body against it, falling back to the old markdown hash for comments rendered before the baseline existed. Both detection sites use it: the save scan and the edit-current-comment no-op check. The markdown hash stays for the remote-conflict gate.
| -rw-r--r-- | pearl.el | 62 | ||||
| -rw-r--r-- | tests/test-pearl-comment-editing.el | 52 |
2 files changed, 87 insertions, 27 deletions
@@ -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 |
