aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 19:48:39 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 19:48:39 -0500
commit648893bd6ff5f4c403733d03258dbf3e91f2394b (patch)
tree3556dde1c81bf9ba0caf62ad6670803d03a60432
parente7f15034f1b4dea7fb22ebc0f2f532ee06b3dd57 (diff)
downloadpearl-648893bd6ff5f4c403733d03258dbf3e91f2394b.tar.gz
pearl-648893bd6ff5f4c403733d03258dbf3e91f2394b.zip
feat(save): detect dirty structured fields against synced baselines
The save scan now flags priority, state, assignee, and labels as dirty when their live value differs from the synced baseline, so structured fields ride the same reconcile-at-save path as title and description. Each check is id/scalar-only and runs with no network: the priority cookie's number against LINEAR-PRIORITY, the explicit state id against LINEAR-STATE-ID-SYNCED (the picker arm — keyword cycling lands later), the assignee id against its baseline, and the label id set against its baseline (order-insensitive, since Linear label order isn't meaningful). A missing baseline on a legacy file reads as not-dirty; the saver's missing-property guard covers that edge. This also makes the priority cookie faithful. The renderer mapped both None and Medium to [#C], so a None issue couldn't be told apart from a Medium one and would have read dirty the moment its baseline said 0. None now renders with no cookie, 1:1 with the four cookie levels — matching what the priority setter already did.
-rw-r--r--pearl.el64
-rw-r--r--tests/test-pearl-format.el4
-rw-r--r--tests/test-pearl-mapping.el16
-rw-r--r--tests/test-pearl-save.el62
4 files changed, 128 insertions, 18 deletions
diff --git a/pearl.el b/pearl.el
index b0c48ca..abd0166 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1655,14 +1655,16 @@ the mapping changes, so a mid-session `setq' or customization takes effect."
pearl-todo-states-pattern)
(defun pearl--map-linear-priority-to-org (priority-num)
- "Convert Linear PRIORITY-NUM to an Org priority cookie.
-PRIORITY-NUM is 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low."
+ "Convert Linear PRIORITY-NUM to an Org priority cookie, or \"\" for none.
+PRIORITY-NUM is 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low. None (0), nil, and
+any unknown value map to no cookie -- 1:1 with the cookie so a None issue does
+not collide with Medium, which the save-model relies on for dirty detection."
(cond
((eq priority-num 1) "[#A]") ; Urgent -> A
((eq priority-num 2) "[#B]") ; High -> B
((eq priority-num 3) "[#C]") ; Medium -> C
((eq priority-num 4) "[#D]") ; Low -> D
- (t "[#C]"))) ; Default -> C
+ (t ""))) ; None / nil / unknown -> no cookie
(defun pearl--get-linear-priority-name (priority-num)
"Convert Linear PRIORITY-NUM to a readable name.
@@ -1898,7 +1900,9 @@ identifier prefix -- so an unedited heading is a no-op on title sync)."
(plist-get issue :labels) " "))
(body-org (pearl--md-to-org description)))
(concat
- (format "** %s %s %s\n" todo priority heading-title)
+ (format "** %s %s%s\n" todo
+ (if (string-empty-p priority) "" (concat priority " "))
+ heading-title)
":PROPERTIES:\n"
(format ":LINEAR-ID: %s\n" (or (plist-get issue :id) ""))
(format ":LINEAR-IDENTIFIER: %s\n" (or (plist-get issue :identifier) ""))
@@ -3250,17 +3254,61 @@ does). A candidate only means the body differs; ownership is decided later."
candidates))))))))
(nreverse candidates)))
+(defun pearl--priority-number-at-point ()
+ "Return the Linear priority (0-4) implied by the heading cookie at point.
+A/B/C/D map to Urgent/High/Medium/Low (1-4); no cookie is None (0)."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((case-fold-search nil))
+ (if (re-search-forward "\\[#\\([A-D]\\)\\]" (line-end-position) t)
+ (cdr (assoc (match-string 1) '(("A" . 1) ("B" . 2) ("C" . 3) ("D" . 4))))
+ 0))))
+
+(defun pearl--priority-dirty-p ()
+ "Non-nil if the heading's priority cookie differs from `LINEAR-PRIORITY'.
+A missing baseline (a legacy file rendered before this field) is not dirty -- a
+refresh writes the baseline; the saver's missing-property guard covers the edge."
+ (let ((base (org-entry-get nil "LINEAR-PRIORITY")))
+ (and base
+ (/= (pearl--priority-number-at-point) (string-to-number base)))))
+
+(defun pearl--id-baseline-dirty-p (live-prop base-prop)
+ "Non-nil if LIVE-PROP differs from its present baseline BASE-PROP at point.
+A missing baseline is not dirty (see `pearl--priority-dirty-p')."
+ (let ((base (org-entry-get nil base-prop)))
+ (and base
+ (not (string= (or (org-entry-get nil live-prop) "") base)))))
+
+(defun pearl--label-ids-dirty-p ()
+ "Non-nil if `LINEAR-LABEL-IDS' differs from `LINEAR-LABEL-IDS-SYNCED' as a set.
+Order-insensitive (Linear label order is not meaningful). A missing baseline is
+not dirty."
+ (let ((base (org-entry-get nil "LINEAR-LABEL-IDS-SYNCED")))
+ (and base
+ (not (equal (sort (split-string (or (org-entry-get nil "LINEAR-LABEL-IDS") "") " " t)
+ #'string<)
+ (sort (split-string base " " t) #'string<))))))
+
(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')."
+A plist: `:title', `:description', `:priority', `:state', `:assignee', and
+`:labels' are booleans; `:comment-candidates' is the list from
+`pearl--changed-comment-candidates'. The structured-field checks compare each
+live value against its synced baseline (id/scalar only, no name resolution or
+remote read). `:state' here is the picker arm only -- explicit
+`LINEAR-STATE-ID' vs `LINEAR-STATE-ID-SYNCED'; the keyword-cycle arm is gated
+on keyword derivation (a later task). This is Phase A of the two-phase save
+scan -- comment ownership 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)
+ :priority (pearl--priority-dirty-p)
+ :state (pearl--id-baseline-dirty-p "LINEAR-STATE-ID" "LINEAR-STATE-ID-SYNCED")
+ :assignee (pearl--id-baseline-dirty-p "LINEAR-ASSIGNEE-ID" "LINEAR-ASSIGNEE-ID-SYNCED")
+ :labels (pearl--label-ids-dirty-p)
:comment-candidates (pearl--changed-comment-candidates))))
(defun pearl--classify-comment-candidates (candidates viewer-id)
diff --git a/tests/test-pearl-format.el b/tests/test-pearl-format.el
index a642b53..ece64d3 100644
--- a/tests/test-pearl-format.el
+++ b/tests/test-pearl-format.el
@@ -105,7 +105,7 @@
"Null/missing optional fields render as empty values, and the body is empty."
(test-pearl--with-default-mapping
(let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-bare))))
- (should (string-match-p "^\\*\\* TODO \\[#C\\] ENG-7: Bare Issue$" out))
+ (should (string-match-p "^\\*\\* TODO ENG-7: Bare Issue$" out))
(should (string-match-p "^:LINEAR-PROJECT-NAME: +$" out))
(should (string-match-p "^:LINEAR-ASSIGNEE-NAME: +$" out))
(should (string-match-p "^:LINEAR-LABELS: +\\[\\]$" out))
@@ -174,7 +174,7 @@ sort together (org-sort on the parent) instead of being orphan headings."
(let ((out (pearl--build-org-content
(list (test-pearl--norm-full) (test-pearl--norm-bare)))))
(should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing$" out))
- (should (string-match-p "^\\*\\* TODO \\[#C\\] ENG-7: Bare Issue$" out)))))
+ (should (string-match-p "^\\*\\* TODO ENG-7: Bare Issue$" out)))))
;;; --restore-page-visibility
diff --git a/tests/test-pearl-mapping.el b/tests/test-pearl-mapping.el
index a4e02e7..d9854cb 100644
--- a/tests/test-pearl-mapping.el
+++ b/tests/test-pearl-mapping.el
@@ -106,14 +106,14 @@ outlive a change to `pearl-state-to-todo-mapping'."
(should (string-equal "[#C]" (pearl--map-linear-priority-to-org 3)))
(should (string-equal "[#D]" (pearl--map-linear-priority-to-org 4))))
-(ert-deftest test-pearl-map-linear-priority-to-org-zero-defaults-c ()
- "Priority 0 (No priority) falls back to [#C]."
- (should (string-equal "[#C]" (pearl--map-linear-priority-to-org 0))))
-
-(ert-deftest test-pearl-map-linear-priority-to-org-nil-and-unknown-default-c ()
- "A nil or out-of-range priority falls back to [#C]."
- (should (string-equal "[#C]" (pearl--map-linear-priority-to-org nil)))
- (should (string-equal "[#C]" (pearl--map-linear-priority-to-org 99))))
+(ert-deftest test-pearl-map-linear-priority-to-org-none-no-cookie ()
+ "Priority 0 (No priority) maps to no cookie, distinct from Medium's [#C]."
+ (should (string-equal "" (pearl--map-linear-priority-to-org 0))))
+
+(ert-deftest test-pearl-map-linear-priority-to-org-nil-and-unknown-no-cookie ()
+ "A nil or out-of-range priority maps to no cookie."
+ (should (string-equal "" (pearl--map-linear-priority-to-org nil)))
+ (should (string-equal "" (pearl--map-linear-priority-to-org 99))))
;;; pearl--get-linear-priority-name
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
index a8ae6af..36358ad 100644
--- a/tests/test-pearl-save.el
+++ b/tests/test-pearl-save.el
@@ -112,6 +112,68 @@
(re-search-forward "Original description")
(should-not (plist-get (pearl--issue-dirty-fields) :comment-candidates))))
+;;; --issue-dirty-fields: structured fields (save-model v2)
+
+(defun test-pearl-save--structured-issue ()
+ "A normalized issue with state id, assignee, and labels (for structured dirty)."
+ '(:id "u" :identifier "ENG-1" :title "t" :priority 2
+ :state (:id "s1" :name "Todo")
+ :assignee (:id "a1" :name "Craig")
+ :labels ((:id "l1" :name "bug") (:id "l2" :name "backend"))
+ :description "body"))
+
+(ert-deftest test-pearl-dirty-fields-structured-clean ()
+ "A freshly rendered issue has no dirty structured fields."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (let ((d (pearl--issue-dirty-fields)))
+ (should-not (plist-get d :priority))
+ (should-not (plist-get d :state))
+ (should-not (plist-get d :assignee))
+ (should-not (plist-get d :labels)))))
+
+(ert-deftest test-pearl-dirty-fields-priority-cookie-change ()
+ "Changing the priority cookie marks priority dirty vs LINEAR-PRIORITY."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (let ((org-priority-highest ?A) (org-priority-lowest ?D))
+ (org-priority ?C)) ; was [#B] (2); now [#C] (3)
+ (should (plist-get (pearl--issue-dirty-fields) :priority))))
+
+(ert-deftest test-pearl-dirty-fields-priority-none-not-dirty ()
+ "A None-priority issue renders no cookie and is not falsely dirty."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-2" :title "t" :priority 0
+ :state (:id "s1" :name "Todo") :description "body")
+ (should-not (plist-get (pearl--issue-dirty-fields) :priority))))
+
+(ert-deftest test-pearl-dirty-fields-state-picker-change ()
+ "An explicit state id different from the synced baseline marks state dirty."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-STATE-ID" "s2")
+ (should (plist-get (pearl--issue-dirty-fields) :state))))
+
+(ert-deftest test-pearl-dirty-fields-assignee-change ()
+ "An assignee id different from the synced baseline marks assignee dirty."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-ASSIGNEE-ID" "a2")
+ (should (plist-get (pearl--issue-dirty-fields) :assignee))))
+
+(ert-deftest test-pearl-dirty-fields-labels-change ()
+ "A label id set different from the synced baseline marks labels dirty."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-LABEL-IDS" "l2 l3")
+ (should (plist-get (pearl--issue-dirty-fields) :labels))))
+
+(ert-deftest test-pearl-dirty-fields-labels-reorder-not-dirty ()
+ "Reordering the same label ids is not dirty (order-insensitive set compare)."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-LABEL-IDS" "l2 l1")
+ (should-not (plist-get (pearl--issue-dirty-fields) :labels))))
+
;;; --classify-comment-candidates (Phase B, by viewer id)
(ert-deftest test-pearl-classify-comment-candidates ()