aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 21:06:42 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 21:06:42 -0500
commitf9841539b3adb192d0c15470fa9c31d6cbbdf447 (patch)
tree490433f58ccb3106e72b6685a7b3d99b3699788a
parent684068a81103e9a9dace2f5cc6fd49edc1f01ab1 (diff)
downloadpearl-f9841539b3adb192d0c15470fa9c31d6cbbdf447.tar.gz
pearl-f9841539b3adb192d0c15470fa9c31d6cbbdf447.zip
feat(save): reconcile structured fields in save-issue and save-all
The four structured savers now run from the save engine. pearl--save-field-thunks queues a priority / state / assignee / labels saver for each dirty structured field alongside title, description, and comments, so save-issue and save-all reconcile a changed cookie, keyword-picker id, assignee, or label set at save time like any other edit. save-all's confirm prompt and the dirty-scan gate both count the structured fields, and a new pearl--issue-has-dirty-fields-p replaces the title/description/comment check that was duplicated in save-issue and the file-wide scan. This is the commit that turns the engine on. The old immediate-push setters still exist until the next commit retires them, so for this one commit both paths are live — the setter cutover and the org-sync removal follow.
-rw-r--r--pearl.el67
-rw-r--r--tests/test-pearl-save.el37
2 files changed, 87 insertions, 17 deletions
diff --git a/pearl.el b/pearl.el
index e7e67e5..9a17662 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3863,13 +3863,32 @@ push sends `labelIds'."
(org-entry-put marker "LINEAR-LABEL-IDS-SYNCED" remote-str)))))
(pearl--run-atomic-field-save spec callback))))
+(defun pearl--issue-has-dirty-fields-p (dirty)
+ "Non-nil if the DIRTY plist (from `pearl--issue-dirty-fields') has any change."
+ (or (plist-get dirty :title)
+ (plist-get dirty :description)
+ (plist-get dirty :priority)
+ (plist-get dirty :state)
+ (plist-get dirty :assignee)
+ (plist-get dirty :labels)
+ (plist-get dirty :comment-candidates)))
+
(defun pearl--save-field-thunks (marker dirty viewer-id)
"Build the ordered save thunks for the DIRTY fields of the issue at MARKER.
-Title first, then description, then each changed comment candidate (VIEWER-ID
-gates ownership inside `pearl--save-comment-field')."
+Title, then the structured fields (priority / state / assignee / labels), then
+description, then each changed comment candidate (VIEWER-ID gates ownership
+inside `pearl--save-comment-field')."
(let (thunks)
(when (plist-get dirty :title)
(push (lambda (done) (pearl--save-title-field marker done)) thunks))
+ (when (plist-get dirty :priority)
+ (push (lambda (done) (pearl--save-priority-field marker done)) thunks))
+ (when (plist-get dirty :state)
+ (push (lambda (done) (pearl--save-state-field marker done)) thunks))
+ (when (plist-get dirty :assignee)
+ (push (lambda (done) (pearl--save-assignee-field marker done)) thunks))
+ (when (plist-get dirty :labels)
+ (push (lambda (done) (pearl--save-labels-field marker done)) thunks))
(when (plist-get dirty :description)
(push (lambda (done) (pearl--save-description-field marker done)) thunks))
(dolist (c (plist-get dirty :comment-candidates))
@@ -3893,19 +3912,18 @@ was pushed. The summary is labelled with the issue identifier."
(defun pearl-save-issue ()
"Save every changed field of the Linear issue at point in one pass.
Runs from anywhere inside an issue subtree. A local dirty scan (no network)
-picks which of the title, description, and your own comments changed; the
-viewer is resolved once, and only if comments are dirty; then each dirty field
-is saved sequentially through its conflict gate and a single summary is
-reported. A clean issue does no network at all."
+picks which of the title, structured fields (priority / state / assignee /
+labels), description, and your own comments changed; the viewer is resolved
+once, and only if comments are dirty; then each dirty field is saved
+sequentially through its conflict gate and a single summary is reported. A
+clean issue does no network at all."
(interactive)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let* ((marker (point-marker))
(buffer (current-buffer))
(dirty (pearl--issue-dirty-fields)))
- (if (not (or (plist-get dirty :title)
- (plist-get dirty :description)
- (plist-get dirty :comment-candidates)))
+ (if (not (pearl--issue-has-dirty-fields-p dirty))
(message "Nothing to save for %s"
(or (org-entry-get nil "LINEAR-IDENTIFIER")
(org-entry-get nil "LINEAR-ID")))
@@ -3925,9 +3943,7 @@ wait for the next save (each field's content is still re-read at push)."
(dolist (cell (pearl--issue-subtree-markers))
(let* ((marker (cdr cell))
(dirty (org-with-point-at marker (pearl--issue-dirty-fields))))
- (when (or (plist-get dirty :title)
- (plist-get dirty :description)
- (plist-get dirty :comment-candidates))
+ (when (pearl--issue-has-dirty-fields-p dirty)
(push (cons marker dirty) result))))
(nreverse result)))
@@ -3936,15 +3952,19 @@ wait for the next save (each field's content is still re-read at push)."
When VIEWER-FAILED is non-nil (the viewer lookup ran and failed), every comment
candidate counts as `:viewer-unavailable' rather than being classified as own /
read-only -- a nil VIEWER-ID then means \"couldn't tell\", not \"none are
-yours\". Returns a plist (:issues :titles :descriptions :own-comments
-:read-only-comments :viewer-unavailable)."
- (let ((issues 0) (titles 0) (descriptions 0) (own 0) (read-only 0)
- (viewer-unavailable 0))
+yours\". Returns a plist (:issues :titles :descriptions :priorities :states
+:assignees :labels :own-comments :read-only-comments :viewer-unavailable)."
+ (let ((issues 0) (titles 0) (descriptions 0) (priorities 0) (states 0)
+ (assignees 0) (labels 0) (own 0) (read-only 0) (viewer-unavailable 0))
(dolist (cell scan)
(let ((dirty (cdr cell)))
(setq issues (1+ issues))
(when (plist-get dirty :title) (setq titles (1+ titles)))
(when (plist-get dirty :description) (setq descriptions (1+ descriptions)))
+ (when (plist-get dirty :priority) (setq priorities (1+ priorities)))
+ (when (plist-get dirty :state) (setq states (1+ states)))
+ (when (plist-get dirty :assignee) (setq assignees (1+ assignees)))
+ (when (plist-get dirty :labels) (setq labels (1+ labels)))
(let ((cands (plist-get dirty :comment-candidates)))
(if viewer-failed
(setq viewer-unavailable (+ viewer-unavailable (length cands)))
@@ -3952,6 +3972,7 @@ yours\". Returns a plist (:issues :titles :descriptions :own-comments
(setq own (+ own (length (plist-get split :own))))
(setq read-only (+ read-only (length (plist-get split :read-only)))))))))
(list :issues issues :titles titles :descriptions descriptions
+ :priorities priorities :states states :assignees assignees :labels labels
:own-comments own :read-only-comments read-only
:viewer-unavailable viewer-unavailable)))
@@ -3959,16 +3980,28 @@ yours\". Returns a plist (:issues :titles :descriptions :own-comments
"Return the one-time confirmation prompt string for the dirty COUNTS plist."
(let* ((titles (plist-get counts :titles))
(descriptions (plist-get counts :descriptions))
+ (priorities (or (plist-get counts :priorities) 0))
+ (states (or (plist-get counts :states) 0))
+ (assignees (or (plist-get counts :assignees) 0))
+ (labels (or (plist-get counts :labels) 0))
(own (plist-get counts :own-comments))
(read-only (plist-get counts :read-only-comments))
(viewer-unavailable (or (plist-get counts :viewer-unavailable) 0))
- (fields (+ titles descriptions own))
+ (fields (+ titles descriptions priorities states assignees labels own))
(issues (plist-get counts :issues))
parts skips)
(when (> titles 0)
(push (format "%d title%s" titles (if (= titles 1) "" "s")) parts))
(when (> descriptions 0)
(push (format "%d description%s" descriptions (if (= descriptions 1) "" "s")) parts))
+ (when (> priorities 0)
+ (push (format "%d priorit%s" priorities (if (= priorities 1) "y" "ies")) parts))
+ (when (> states 0)
+ (push (format "%d state%s" states (if (= states 1) "" "s")) parts))
+ (when (> assignees 0)
+ (push (format "%d assignee%s" assignees (if (= assignees 1) "" "s")) parts))
+ (when (> labels 0)
+ (push (format "%d label%s" labels (if (= labels 1) "" "s")) parts))
(when (> own 0)
(push (format "%d comment%s" own (if (= own 1) "" "s")) parts))
(when (> read-only 0)
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
index 8df7b3b..65f742a 100644
--- a/tests/test-pearl-save.el
+++ b/tests/test-pearl-save.el
@@ -676,5 +676,42 @@ description hash is left untouched and the title still saved."
;; only the still-valid second issue saved
(should (= 1 (length desc-pushes)))))))
+;;; structured fields wired into save-issue / save-all (save-model v2)
+
+(ert-deftest test-pearl-save-issue-routes-dirty-structured-field ()
+ "save-issue picks up a dirty structured field (assignee) and pushes it."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-1" :title "t" :priority 2
+ :state (:id "s1" :name "Todo") :assignee (:id "a1" :name "Craig")
+ :description "body")
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-ASSIGNEE-ID" "a2") ; the picker chose a new assignee
+ (let (pushed-input)
+ (cl-letf (((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb '((assignee (id . "a1") (name . "Craig"))))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb)
+ (setq pushed-input input) (funcall cb '(:success t)))))
+ (pearl-save-issue)
+ (should (string= "a2" (cdr (assoc "assigneeId" pushed-input))))
+ (should (string= "a2" (org-entry-get nil "LINEAR-ASSIGNEE-ID-SYNCED")))))))
+
+(ert-deftest test-pearl-save-all-counts-and-prompt-structured-fields ()
+ "save-all tallies and names priority / state / assignee / labels in the prompt."
+ (let* ((scan (list (cons (make-marker) '(:priority t))
+ (cons (make-marker) '(:state t :labels t :assignee t))))
+ (counts (pearl--save-all-counts scan nil)))
+ (should (= 2 (plist-get counts :issues)))
+ (should (= 1 (plist-get counts :priorities)))
+ (should (= 1 (plist-get counts :states)))
+ (should (= 1 (plist-get counts :assignees)))
+ (should (= 1 (plist-get counts :labels)))
+ (let ((prompt (pearl--save-all-prompt counts)))
+ (should (string-match-p "1 priority" prompt))
+ (should (string-match-p "1 state" prompt))
+ (should (string-match-p "1 assignee" prompt))
+ (should (string-match-p "1 label" prompt))
+ (should (string-match-p "Save 4 fields across 2 issues" prompt)))))
+
(provide 'test-pearl-save)
;;; test-pearl-save.el ends here