diff options
| -rw-r--r-- | pearl.el | 64 | ||||
| -rw-r--r-- | tests/test-pearl-format.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-mapping.el | 16 | ||||
| -rw-r--r-- | tests/test-pearl-save.el | 62 |
4 files changed, 128 insertions, 18 deletions
@@ -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 () |
