aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 18:46:58 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 18:46:58 -0500
commit617d3a13fa23b472879327366c6c557de9cb90f4 (patch)
tree1b275174b5e20090d13701b9df0ee1719aa630f1
parent99908a72283e16f060239cff176a3b971e509d3c (diff)
downloadpearl-617d3a13fa23b472879327366c6c557de9cb90f4.tar.gz
pearl-617d3a13fa23b472879327366c6c557de9cb90f4.zip
feat: add the local dirty scanners for the ticket save model
First layer of the unified save model (docs/ticket-save-model-spec.org). pearl--issue-dirty-fields returns which of title, description, and comment candidates changed in the issue subtree at point, with no network call. Description dirtiness reuses pearl--subtree-dirty-p, which hashes the rendered Org against LINEAR-DESC-ORG-SHA256, so a description whose markdown is lossy under org->md isn't falsely flagged. Comment candidates hash org->md of each body against LINEAR-COMMENT-SHA256, matching how pearl-edit-current-comment computes its no-op check. Ownership is a separate phase: pearl--classify-comment-candidates splits candidates into own versus read-only once the viewer id is known, since the local scan can't tell who authored a comment without it. The save-issue and save-all commands that consume these scanners land in the next layers.
-rw-r--r--pearl.el51
-rw-r--r--tests/test-pearl-save.el123
2 files changed, 174 insertions, 0 deletions
diff --git a/pearl.el b/pearl.el
index 4b31b2f..da7161b 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3229,6 +3229,57 @@ track correctly across the in-place inserts and deletes the merge performs."
(push (cons id (copy-marker (point) t)) markers))))))
(nreverse markers)))
+;;; Dirty detection (the save model's local scanners)
+
+(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."
+ (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--issue-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))))))))
+ (nreverse candidates)))
+
+(defun pearl--issue-dirty-fields ()
+ "Return the locally-changed fields of the issue subtree at point, no network.
+A plist: `:title' and `:description' are booleans; `:comment-candidates' is the
+list from `pearl--changed-comment-candidates'. This is Phase A of the two-phase
+save scan -- comment ownership (own vs read-only) is classified separately once
+the viewer id is known (see `pearl--classify-comment-candidates')."
+ (save-excursion
+ (pearl--goto-heading-or-error)
+ (list :title (not (string= (secure-hash 'sha256 (pearl--issue-title-at-point))
+ (or (org-entry-get nil "LINEAR-TITLE-SHA256") "")))
+ :description (pearl--subtree-dirty-p)
+ :comment-candidates (pearl--changed-comment-candidates))))
+
+(defun pearl--classify-comment-candidates (candidates viewer-id)
+ "Split CANDIDATES into own dirty comments and read-only ones, by VIEWER-ID.
+Returns a plist (:own (...) :read-only (...)). A candidate is `:own' when its
+`:author-id' equals VIEWER-ID (see `pearl--comment-editable-p'); a non-own /
+bot / external comment is `:read-only' -- edited locally but not pushable."
+ (let (own read-only)
+ (dolist (c candidates)
+ (if (pearl--comment-editable-p (plist-get c :author-id) viewer-id)
+ (push c own)
+ (push c read-only)))
+ (list :own (nreverse own) :read-only (nreverse read-only))))
+
(defun pearl--merge-issues-into-buffer (issues)
"Merge normalized ISSUES into the current buffer by `LINEAR-ID'.
Same-source refresh semantics: an existing issue still in ISSUES is re-rendered
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
new file mode 100644
index 0000000..24db264
--- /dev/null
+++ b/tests/test-pearl-save.el
@@ -0,0 +1,123 @@
+;;; test-pearl-save.el --- Tests for the unified ticket save model -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; 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.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the unified ticket save model (see
+;; docs/ticket-save-model-spec.org). Phase 1: the local dirty scanners
+;; (`pearl--issue-dirty-fields', `pearl--changed-comment-candidates') and the
+;; viewer-based ownership classifier (`pearl--classify-comment-candidates').
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+(defun test-pearl-save--issue ()
+ "A normalized issue with a description and one own comment (author-id u-craig)."
+ '(:id "u" :identifier "ENG-1" :title "fix the bug" :priority 2
+ :state (:name "Todo") :description "Original description."
+ :comments ((:id "c1" :author "Craig" :author-id "u-craig"
+ :created-at "2026-05-24T00:00:00.000Z" :body "my comment"))))
+
+(defmacro test-pearl-save--in-rendered (issue &rest body)
+ "Render ISSUE into an org buffer, then run BODY with point at the start."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping '(("Todo" . "TODO")))
+ (pearl-comment-sort-order 'newest-first))
+ (with-temp-buffer
+ (insert (pearl--format-issue-as-org-entry ,issue))
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; --issue-dirty-fields (Phase A, local)
+
+(ert-deftest test-pearl-dirty-fields-clean-issue ()
+ "A freshly rendered issue has no dirty fields and no comment candidates."
+ (test-pearl-save--in-rendered (test-pearl-save--issue)
+ (re-search-forward "Original description")
+ (let ((d (pearl--issue-dirty-fields)))
+ (should-not (plist-get d :title))
+ (should-not (plist-get d :description))
+ (should-not (plist-get d :comment-candidates)))))
+
+(ert-deftest test-pearl-dirty-fields-title-edit ()
+ "Editing the heading title (past the identifier prefix) marks the title dirty."
+ (test-pearl-save--in-rendered (test-pearl-save--issue)
+ (re-search-forward "Fix the Bug")
+ (org-back-to-heading t)
+ (org-edit-headline "ENG-1: Fix the Cache Bug")
+ (let ((d (pearl--issue-dirty-fields)))
+ (should (plist-get d :title))
+ (should-not (plist-get d :description)))))
+
+(ert-deftest test-pearl-dirty-fields-description-edit ()
+ "Editing the body marks the description dirty, not the title."
+ (test-pearl-save--in-rendered (test-pearl-save--issue)
+ (re-search-forward "Original description\\.")
+ (end-of-line)
+ (insert " EDITED")
+ (let ((d (pearl--issue-dirty-fields)))
+ (should (plist-get d :description))
+ (should-not (plist-get d :title)))))
+
+(ert-deftest test-pearl-dirty-fields-lossy-description-stays-clean ()
+ "A description whose markdown is lossy under org->md is not falsely dirty (HP1)."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-2" :title "t" :priority 3
+ :state (:name "Todo") :description "# Heading\nbody text")
+ (re-search-forward "body text")
+ (should-not (plist-get (pearl--issue-dirty-fields) :description))))
+
+(ert-deftest test-pearl-dirty-fields-comment-candidate ()
+ "An edited comment surfaces as a candidate carrying its id and author-id."
+ (test-pearl-save--in-rendered (test-pearl-save--issue)
+ ;; edit the comment body
+ (re-search-forward "my comment")
+ (end-of-line)
+ (insert " edited")
+ ;; scan from inside the issue body (not the comment)
+ (goto-char (point-min))
+ (re-search-forward "Original description")
+ (let ((cands (plist-get (pearl--issue-dirty-fields) :comment-candidates)))
+ (should (= 1 (length cands)))
+ (should (string= "c1" (plist-get (car cands) :comment-id)))
+ (should (string= "u-craig" (plist-get (car cands) :author-id))))))
+
+(ert-deftest test-pearl-dirty-fields-unedited-comment-no-candidate ()
+ "An unedited comment is not a candidate."
+ (test-pearl-save--in-rendered (test-pearl-save--issue)
+ (re-search-forward "Original description")
+ (should-not (plist-get (pearl--issue-dirty-fields) :comment-candidates))))
+
+;;; --classify-comment-candidates (Phase B, by viewer id)
+
+(ert-deftest test-pearl-classify-comment-candidates ()
+ "Candidates split into own (author = viewer) and read-only (everyone else)."
+ (let* ((cands '((:comment-id "c1" :author-id "u-craig")
+ (:comment-id "c2" :author-id "u-other")
+ (:comment-id "c3" :author-id ""))) ; bot/external: not own
+ (r (pearl--classify-comment-candidates cands "u-craig")))
+ (should (equal '("c1") (mapcar (lambda (c) (plist-get c :comment-id))
+ (plist-get r :own))))
+ (should (equal '("c2" "c3") (mapcar (lambda (c) (plist-get c :comment-id))
+ (plist-get r :read-only))))))
+
+(provide 'test-pearl-save)
+;;; test-pearl-save.el ends here