diff options
| -rw-r--r-- | docs/ticket-save-model-spec.org | 16 | ||||
| -rw-r--r-- | pearl.el | 51 | ||||
| -rw-r--r-- | tests/test-pearl-save.el | 123 |
3 files changed, 182 insertions, 8 deletions
diff --git a/docs/ticket-save-model-spec.org b/docs/ticket-save-model-spec.org index 8d7bc9a..40066ce 100644 --- a/docs/ticket-save-model-spec.org +++ b/docs/ticket-save-model-spec.org @@ -19,7 +19,7 @@ Editing a ticket today means picking the right per-field command: Three free-text fields, three commands, three things to remember. There's no single "I edited this ticket, save it" action, and no way to edit several fields (or several tickets) and push them all at once. In practice the save command would be the most-used command in the package, yet every field command currently has its own keybinding competing for finger memory. -* Current state +* Current State - *Provenance hashes* live in each issue's drawer and already encode "did this change since fetch": - =LINEAR-TITLE-SHA256= — hash of the displayed (bracket-stripped, title-cased, prefix-excluded) title. @@ -33,7 +33,7 @@ Three free-text fields, three commands, three things to remember. There's no sin - *Subtree iteration* exists: =pearl--issue-subtree-markers= walks every issue heading (used by the merge refresh). - *Discoverability* is the transient =pearl-menu=. -* Proposed design +* Proposed Design ** Architecture: a small save engine, not wrappers over interactive commands @@ -179,7 +179,7 @@ The "Issue at point" group loses the two field-sync entries (subsumed by save) a (The transient and the =pearl-prefix-map= are independent surfaces; the table above is the transient. The menu's existing key locks in transient tests, so this is the exact target.) -* Agreed decisions (this feature) +* Agreed Decisions (This Feature) 1. A layered save engine: pure dirty scanners → per-field async savers (marker + callback → structured outcome) → sequential queue runner → thin interactive wrappers. 2. Description dirty detection uses =LINEAR-DESC-ORG-SHA256= first, falling back to the markdown hash only for legacy subtrees; a push advances both hashes; the remote gate still uses the markdown hash. @@ -192,17 +192,17 @@ The "Issue at point" group loses the two field-sync entries (subsumed by save) a 9. Keybindings ship as an opt-in =pearl-prefix-map= (no global bind at load); README documents =C-; L= as a suggested binding. Verb layout =a= / =d= / =e= + =m=. 10. The transient "Issue at point" group is retargeted per the table above; the per-field sync commands stay callable but lose dedicated keys. -* Files touched +* Files Touched - =pearl.el=: =pearl--issue-dirty-fields= (local scanners); per-field async savers extracted from the current interactive commands; the sequential queue runner; =pearl-save-issue= / =pearl-save-all=; the interactive sync commands re-pointed as thin wrappers; =pearl-prefix-map= (define-prefix-command, populated, not bound); the retargeted =pearl-menu= group; description-push advancing both hashes. - =docs/=: this spec. - =README.org=: the save model + the suggested-binding snippet, with the existing title-bracket and markdown-lossiness warnings kept *near* the save docs (a unified save pushes title + description together, so the losses surface in one command). -* Test plan +* Test Plan New focused =tests/test-pearl-save.el= (rather than scattering into the per-field files): -*Dirty scan* +*Dirty Scan* - Clean rendered issue whose markdown is lossy under =org->md= → empty (via =LINEAR-DESC-ORG-SHA256=). - Legacy issue lacking the Org hash → falls back to the markdown hash. - Reports title-only, description-only, own-comment-only, multiple own comments, and mixed title+description+comment. @@ -233,7 +233,7 @@ New focused =tests/test-pearl-save.el= (rather than scattering into the per-fiel - Transient keeps the retargeted suffixes (locks the menu group). - README examples use the final command names and keep the lossiness warnings near the save docs. -* Review dispositions +* Review Dispositions *Round 1 (Codex, 2026-05-24).* Everything was accepted and woven into the body, *except* the two below, which were modified. @@ -244,7 +244,7 @@ Everything else — HP1 (Org-hash-first dirty detection), HP2 (structured outcom *Round 2 (Codex, 2026-05-24).* All accepted as written; no modifications. HP1 (two-phase comment ownership) corrected a genuine contradiction this author introduced in round 1 — the "no remote calls" save-all scan couldn't count read-only comment skips without the viewer id — and pinned the comment hash to =org->md= against =LINEAR-COMMENT-SHA256= (verified against =pearl--format-comment= and =pearl-edit-current-comment=). Folded in as the two-phase rule with a permitted pre-confirmation read-only viewer lookup. MP1 (multi-account not a prerequisite) and MP2 (stale =todo.org= wording) accepted; the stale task decisions block was trimmed to point at this spec rather than carry superseded pre-spec decisions. -* vNext / out of scope +* vNext / Out of Scope - Review-changes-before-save diff buffer (=git add -p= style) across the file before pushing. - A save-all dry-run command that reports dirty fields without prompting to push. @@ -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 |
