aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 20:20:00 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 20:20:00 -0500
commit09cf1f02161aaef9bb1ad26907472bea52f53a24 (patch)
tree9ad920b04eaa01d0e8bf0744fc4384a865956118
parent8fa0febbc3a3afe78d1a5abbbb944adf6dd3612a (diff)
downloadpearl-09cf1f02161aaef9bb1ad26907472bea52f53a24.tar.gz
pearl-09cf1f02161aaef9bb1ad26907472bea52f53a24.zip
feat(save): add the atomic field-save engine and the assignee saver
pearl--run-atomic-field-save is the structured-field sibling of the text engine pearl--run-field-save. It runs the atomic three-way over a spec's live and baseline values. A missing baseline skips with missing-property (a legacy file — refresh first). Live equal to baseline is unchanged. Otherwise it fetches the remote and compares: remote already equal to live converges and advances the baseline, remote unmoved from baseline is a clean push, and a genuine divergence runs the atomic conflict gate. It reuses the existing save-outcome contract, so structured fields report the same status/reason shape as title and description. pearl--save-assignee-field is the first saver on it — the cleanest relation field. Its live id is LINEAR-ASSIGNEE-ID diffed against LINEAR-ASSIGNEE-ID-SYNCED, the remote comes from the issue node, the push sends assigneeId, and use-remote rewrites the display name from the fetched node. Priority, state, and labels follow the same template in the next commit. It's not wired into save-issue yet — that's the next commit. I tested it in isolation: the engine's three-way and conflict branches over a synthetic spec, plus the assignee saver end to end with a stubbed fetch and update.
-rw-r--r--pearl.el110
-rw-r--r--tests/test-pearl-save.el109
2 files changed, 219 insertions, 0 deletions
diff --git a/pearl.el b/pearl.el
index 7d2210e..ae694c1 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3608,6 +3608,116 @@ Surface BUFFER when any field pushed. The shared close-out for
(message "%s: %s" label (pearl--save-summary-message outcomes))
(when pushed (pearl--surface-buffer buffer))))
+(defun pearl--run-atomic-field-save (spec callback)
+ "Save one atomic (enum/relation) field per SPEC, calling CALLBACK once.
+SPEC keys: :issue-id :identifier :field :marker :label; :live and :baseline
+\(comparison values -- a nil :baseline is a legacy file with no synced
+baseline); :equal (a comparator, default `equal'); :fetch-remote (FN CB; CB
+gets the remote comparison value or `:error'); :push (FN CB; pushes :live, CB
+gets a (:success BOOL) result); :commit-local (FN; advances the baseline to
+:live, already in the buffer); :commit-remote (FN VALUE; writes VALUE into the
+buffer and advances the baseline to it); :live-display (a prompt string) and
+:remote-display (FN VALUE -> string).
+
+The atomic three-way, the sibling of `pearl--run-field-save' for non-text
+fields: a missing baseline is `skipped'/`missing-property'; live equal to
+baseline is `unchanged'; otherwise the remote is fetched (`:error' ->
+`failed'/`fetch-failed') -- remote equal to live converges (advance baseline,
+`unchanged'), remote equal to baseline is a clean push, anything else runs
+`pearl--resolve-atomic-conflict'. No SHA hash and no smerge."
+ (let* ((live (plist-get spec :live))
+ (baseline (plist-get spec :baseline))
+ (equal-fn (or (plist-get spec :equal) #'equal))
+ (push-then-commit
+ (lambda (cb)
+ (funcall (plist-get spec :push)
+ (lambda (r)
+ (when (plist-get r :success)
+ (funcall (plist-get spec :commit-local)))
+ (funcall cb (plist-get r :success)))))))
+ (cond
+ ((null baseline)
+ (funcall callback (pearl--save-outcome spec 'skipped 'missing-property)))
+ ((funcall equal-fn live baseline)
+ (funcall callback (pearl--save-outcome spec 'unchanged nil)))
+ (t
+ (funcall
+ (plist-get spec :fetch-remote)
+ (lambda (remote)
+ (cond
+ ((eq remote :error)
+ (funcall callback (pearl--save-outcome spec 'failed 'fetch-failed)))
+ ((funcall equal-fn remote live)
+ (funcall (plist-get spec :commit-local))
+ (funcall callback (pearl--save-outcome spec 'unchanged nil)))
+ ((funcall equal-fn remote baseline)
+ (funcall push-then-commit
+ (lambda (ok)
+ (funcall callback
+ (pearl--save-outcome spec (if ok 'pushed 'failed)
+ (and (not ok) 'push-failed))))))
+ (t
+ (pearl--resolve-atomic-conflict
+ (plist-get spec :label)
+ (plist-get spec :live-display)
+ (funcall (plist-get spec :remote-display) remote)
+ push-then-commit
+ (lambda () (funcall (plist-get spec :commit-remote) remote))
+ (lambda (status reason)
+ (funcall callback (pearl--save-outcome spec status reason))))))))))))
+
+(defun pearl--node-assignee-id (node)
+ "The assignee id of raw issue NODE, or \"\" when unassigned."
+ (or (cdr (assoc 'id (cdr (assoc 'assignee node)))) ""))
+
+(defun pearl--node-assignee-name (node)
+ "The assignee display name of raw issue NODE, or \"(unassigned)\"."
+ (or (cdr (assoc 'name (cdr (assoc 'assignee node)))) "(unassigned)"))
+
+(defun pearl--save-assignee-field (marker callback)
+ "Save the assignee of the issue subtree at MARKER, calling CALLBACK.
+Picker-authoritative: the live id is `LINEAR-ASSIGNEE-ID', diffed against
+`LINEAR-ASSIGNEE-ID-SYNCED'. A push sends `assigneeId' (`:null' unassigns)."
+ (org-with-point-at marker
+ (let* ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (identifier (org-entry-get nil "LINEAR-IDENTIFIER"))
+ (live (or (org-entry-get nil "LINEAR-ASSIGNEE-ID") ""))
+ (remote-node nil)
+ (spec
+ (list
+ :issue-id issue-id :identifier identifier :field 'assignee
+ :marker marker
+ :label (format "%s assignee" (or identifier issue-id))
+ :live live
+ :baseline (org-entry-get nil "LINEAR-ASSIGNEE-ID-SYNCED")
+ :equal #'string=
+ :live-display (or (org-entry-get nil "LINEAR-ASSIGNEE-NAME") "(unassigned)")
+ :remote-display (lambda (_id) (pearl--node-assignee-name remote-node))
+ :fetch-remote
+ (lambda (cb)
+ (pearl--fetch-issue-async
+ issue-id
+ (lambda (node)
+ (if (memq node '(:error :missing))
+ (funcall cb :error)
+ (setq remote-node node)
+ (funcall cb (pearl--node-assignee-id node))))))
+ :push
+ (lambda (cb)
+ (pearl--update-issue-async
+ issue-id
+ (list (cons "assigneeId" (if (string-empty-p live) :null live)))
+ cb))
+ :commit-local
+ (lambda () (org-entry-put marker "LINEAR-ASSIGNEE-ID-SYNCED" live))
+ :commit-remote
+ (lambda (remote-id)
+ (org-entry-put marker "LINEAR-ASSIGNEE-ID" remote-id)
+ (org-entry-put marker "LINEAR-ASSIGNEE-NAME"
+ (pearl--node-assignee-name remote-node))
+ (org-entry-put marker "LINEAR-ASSIGNEE-ID-SYNCED" remote-id)))))
+ (pearl--run-atomic-field-save spec callback))))
+
(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
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
index 36358ad..edd2c2d 100644
--- a/tests/test-pearl-save.el
+++ b/tests/test-pearl-save.el
@@ -288,6 +288,115 @@ the conflict choice (stubbed). Returns (:outcome :fetched :pushed :applied
(should (equal '("remote changed") (plist-get r :applied)))
(should (string= (secure-hash 'sha256 "remote changed") (plist-get r :hash)))))
+;;; --run-atomic-field-save (the per-field engine for enum/relation fields)
+
+(cl-defun test-pearl-save--atomic (&key live baseline remote (push-success t) resolution)
+ "Drive `pearl--run-atomic-field-save' over a synthetic spec; return a results plist."
+ (let (outcome pushed committed-local committed-remote)
+ (cl-letf (((symbol-function 'pearl--read-atomic-conflict-resolution)
+ (lambda (_) resolution)))
+ (pearl--run-atomic-field-save
+ (list :issue-id "u" :identifier "ENG-1" :field 'assignee :marker nil
+ :label "ENG-1 assignee" :live live :baseline baseline :equal #'string=
+ :live-display (format "L:%s" live)
+ :remote-display (lambda (r) (format "R:%s" r))
+ :fetch-remote (lambda (cb) (funcall cb remote))
+ :push (lambda (cb) (setq pushed t) (funcall cb (list :success push-success)))
+ :commit-local (lambda () (setq committed-local t))
+ :commit-remote (lambda (r) (setq committed-remote r)))
+ (lambda (o) (setq outcome o))))
+ (list :status (plist-get outcome :status) :reason (plist-get outcome :reason)
+ :pushed pushed :local committed-local :remote committed-remote)))
+
+(ert-deftest test-pearl-atomic-missing-baseline-skips ()
+ "A nil baseline (legacy file) is skipped with missing-property, no fetch."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline nil :remote "x")))
+ (should (eq 'skipped (plist-get r :status)))
+ (should (eq 'missing-property (plist-get r :reason)))
+ (should-not (plist-get r :pushed))))
+
+(ert-deftest test-pearl-atomic-live-equals-baseline-unchanged ()
+ "Live equal to baseline is unchanged (defensive; the scan shouldn't queue it)."
+ (let ((r (test-pearl-save--atomic :live "a" :baseline "a" :remote "x")))
+ (should (eq 'unchanged (plist-get r :status)))
+ (should-not (plist-get r :pushed))))
+
+(ert-deftest test-pearl-atomic-remote-converged-advances-no-push ()
+ "Remote already equal to live converges: advance baseline, unchanged, no push."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "b")))
+ (should (eq 'unchanged (plist-get r :status)))
+ (should (plist-get r :local))
+ (should-not (plist-get r :pushed))))
+
+(ert-deftest test-pearl-atomic-clean-push ()
+ "Remote unmoved from baseline: clean push, baseline advances to live."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "a")))
+ (should (eq 'pushed (plist-get r :status)))
+ (should (plist-get r :pushed))
+ (should (plist-get r :local))))
+
+(ert-deftest test-pearl-atomic-clean-push-failure ()
+ "A failed clean push reports failed/push-failed and does not advance baseline."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "a"
+ :push-success nil)))
+ (should (eq 'failed (plist-get r :status)))
+ (should (eq 'push-failed (plist-get r :reason)))
+ (should-not (plist-get r :local))))
+
+(ert-deftest test-pearl-atomic-conflict-use-local ()
+ "Remote moved differently: use-local pushes and advances."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "c"
+ :resolution 'use-local)))
+ (should (eq 'pushed (plist-get r :status)))
+ (should (plist-get r :pushed))
+ (should (plist-get r :local))))
+
+(ert-deftest test-pearl-atomic-conflict-use-remote ()
+ "Use-remote adopts the remote value, never pushes, reports resolved-remote."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "c"
+ :resolution 'use-remote)))
+ (should (eq 'resolved-remote (plist-get r :status)))
+ (should-not (plist-get r :pushed))
+ (should (string= "c" (plist-get r :remote)))))
+
+(ert-deftest test-pearl-atomic-conflict-cancel ()
+ "Cancel leaves the field in conflict, pushes and commits nothing."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote "c"
+ :resolution 'cancel)))
+ (should (eq 'conflict (plist-get r :status)))
+ (should (eq 'cancelled (plist-get r :reason)))
+ (should-not (plist-get r :pushed))
+ (should-not (plist-get r :local))
+ (should-not (plist-get r :remote))))
+
+(ert-deftest test-pearl-atomic-fetch-failure ()
+ "A remote fetch error reports failed/fetch-failed."
+ (let ((r (test-pearl-save--atomic :live "b" :baseline "a" :remote :error)))
+ (should (eq 'failed (plist-get r :status)))
+ (should (eq 'fetch-failed (plist-get r :reason)))))
+
+;;; --save-assignee-field (the relation saver, end to end)
+
+(ert-deftest test-pearl-save-assignee-clean-push-advances-baseline ()
+ "A picker-changed assignee with an unmoved remote pushes and advances the baseline."
+ (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 ((marker (point-marker)) outcome (pushed nil))
+ (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 (cdr (assoc "assigneeId" input)))
+ (funcall cb '(:success t)))))
+ (pearl--save-assignee-field marker (lambda (o) (setq outcome o)))
+ (should (eq 'pushed (plist-get outcome :status)))
+ (should (string= "a2" pushed))
+ (should (string= "a2" (org-entry-get marker "LINEAR-ASSIGNEE-ID-SYNCED")))))))
+
;;; --save-description-field (advances both provenance hashes)
(ert-deftest test-pearl-save-description-advances-both-hashes ()