From f9841539b3adb192d0c15470fa9c31d6cbbdf447 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 25 May 2026 21:06:42 -0500 Subject: feat(save): reconcile structured fields in save-issue and save-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- pearl.el | 67 ++++++++++++++++++++++++++++++++++++------------ tests/test-pearl-save.el | 37 ++++++++++++++++++++++++++ 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 -- cgit v1.2.3