diff options
| -rw-r--r-- | pearl.el | 51 | ||||
| -rw-r--r-- | tests/test-pearl-save.el | 123 |
2 files changed, 174 insertions, 0 deletions
@@ -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 |
