diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-25 20:20:00 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-25 20:20:00 -0500 |
| commit | 09cf1f02161aaef9bb1ad26907472bea52f53a24 (patch) | |
| tree | 9ad920b04eaa01d0e8bf0744fc4384a865956118 /pearl.el | |
| parent | 8fa0febbc3a3afe78d1a5abbbb944adf6dd3612a (diff) | |
| download | pearl-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.
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 110 |
1 files changed, 110 insertions, 0 deletions
@@ -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 |
