aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 00:19:47 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 00:19:47 -0500
commit56511a51afc66bc7efebee657bf040624defde65 (patch)
tree72cf78aa7898e95111a901ee72e55075038edd87 /pearl.el
parentc2fc84a11a0db1df981fcb978a675c516afc2252 (diff)
downloadpearl-56511a51afc66bc7efebee657bf040624defde65.tar.gz
pearl-56511a51afc66bc7efebee657bf040624defde65.zip
feat: add the unified ticket save model with save-issue and save-all
Editing a ticket meant remembering which per-field command to run: pearl-sync-current-issue for the description, pearl-sync-current-issue-title for the heading, pearl-edit-current-comment for a comment. Three fields, three commands, and no single way to edit several fields (or several tickets) and push them at once. I added a layered save engine and two commands over it. pearl--run-field-save does the fetch, the three-way conflict gate, and the push for one field, emitting a single structured outcome instead of messaging from its callback. The three interactive sync commands are now thin wrappers over the per-field savers, so they keep working and gain the outcome. pearl-save-issue diffs the ticket at point and pushes only what changed. pearl-save-all scans the whole file, confirms once naming the field counts, and saves every diff in a sequential pass that continues past a per-ticket conflict. A queue runner keeps at most one conflict-resolution buffer live at a time. I also fixed a correctness bug: a description push now advances both LINEAR-DESC-SHA256 (the markdown the remote gate hashes against) and LINEAR-DESC-ORG-SHA256 (the rendered-Org baseline the local scan uses). Advancing only the markdown hash left the Org hash stale, so the next local scan would flag a just-saved ticket as dirty again. The conflict resolver gained an optional outcome callback so the engine can report cancel, use-local, use-remote, and the deferred smerge commit or abort as distinct outcomes. I removed the now-dead pearl--commit-sync-decision. The keybinding scheme and the transient menu retarget are a separate follow-up. Spec: docs/ticket-save-model-spec.org.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el626
1 files changed, 459 insertions, 167 deletions
diff --git a/pearl.el b/pearl.el
index da7161b..1c6e6f9 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1978,7 +1978,7 @@ resolved description or comment back into the buffer."
(unless (string-empty-p org-text)
(insert org-text "\n")))))
-(defun pearl--resolve-conflict (label local-md remote-md marker stored-prop apply-fn push-fn)
+(defun pearl--resolve-conflict (label local-md remote-md marker stored-prop apply-fn push-fn &optional outcome-cb)
"Interactively resolve a sync conflict on LABEL.
LOCAL-MD and REMOTE-MD are the two diverged versions. MARKER anchors the org
entry; STORED-PROP is the provenance property advanced on resolution (such as
@@ -1989,12 +1989,19 @@ and a callback invoked with non-nil on a successful push.
Resolutions (see `pearl--read-conflict-resolution'): `cancel' leaves both
untouched; `use-local' pushes the local text and advances the hash on success;
`use-remote' stashes the local text, writes Linear's version in, and advances
-the hash with no push (the remote is already current). `rewrite' is the
-deferred smerge flow -- for now it stashes the local text and redirects, so
-nothing is lost."
+the hash with no push (the remote is already current). `rewrite' opens an
+smerge buffer; the reconciled text is applied and pushed on commit, or the
+field is left in conflict on abort (the local text is stashed either way).
+
+OUTCOME-CB, when given, is called once with (STATUS REASON) after the
+resolution settles, for the save engine: `cancel' -> (conflict cancelled),
+`use-local' -> (pushed nil) or (failed push-failed), `use-remote' ->
+\(resolved-remote nil), `rewrite' commit -> (pushed nil) or (failed
+push-failed), `rewrite' abort -> (conflict aborted)."
(pcase (pearl--read-conflict-resolution label)
('cancel
- (message "Left %s untouched (refresh to see Linear's version)" label))
+ (message "Left %s untouched (refresh to see Linear's version)" label)
+ (when outcome-cb (funcall outcome-cb 'conflict 'cancelled)))
('use-local
(funcall push-fn local-md
(lambda (ok)
@@ -2002,14 +2009,17 @@ nothing is lost."
(progn
(org-entry-put marker stored-prop
(secure-hash 'sha256 local-md))
- (message "Pushed your %s to Linear" label))
- (message "Failed to push %s" label)))))
+ (message "Pushed your %s to Linear" label)
+ (when outcome-cb (funcall outcome-cb 'pushed nil)))
+ (message "Failed to push %s" label)
+ (when outcome-cb (funcall outcome-cb 'failed 'push-failed))))))
('use-remote
(pearl--stash-conflict-text label local-md)
(funcall apply-fn remote-md)
(org-entry-put marker stored-prop (secure-hash 'sha256 remote-md))
(message "Took Linear's %s; your version is on the kill ring and in %s"
- label pearl--conflict-backup-buffer))
+ label pearl--conflict-backup-buffer)
+ (when outcome-cb (funcall outcome-cb 'resolved-remote nil)))
('rewrite
(pearl--stash-conflict-text label local-md)
(pearl--resolve-conflict-in-smerge
@@ -2022,8 +2032,11 @@ nothing is lost."
(progn
(org-entry-put marker stored-prop
(secure-hash 'sha256 reconciled))
- (message "Synced merged %s to Linear" label))
- (message "Failed to push merged %s" label)))))))))
+ (message "Synced merged %s to Linear" label)
+ (when outcome-cb (funcall outcome-cb 'pushed nil)))
+ (message "Failed to push merged %s" label)
+ (when outcome-cb (funcall outcome-cb 'failed 'push-failed))))))
+ (lambda () (when outcome-cb (funcall outcome-cb 'conflict 'aborted)))))))
;;; Compose Buffer
;;
@@ -2104,17 +2117,21 @@ sibling of `pearl--resolve-conflict-in-smerge'."
(defvar-local pearl--conflict-on-finish nil
"Callback invoked with the reconciled text when a conflict buffer commits.")
+(defvar-local pearl--conflict-on-abort nil
+ "Thunk invoked when a conflict buffer is aborted, for save-outcome reporting.")
+
(defun pearl--conflict-has-markers-p (text)
"Return non-nil if any git-style conflict markers remain in TEXT.
Used to refuse a commit while the user has left a section unresolved."
(and (string-match-p "^\\(<<<<<<<\\|=======\\|>>>>>>>\\)" text) t))
-(defun pearl--resolve-conflict-in-smerge (label local remote on-finish)
+(defun pearl--resolve-conflict-in-smerge (label local remote on-finish &optional on-abort)
"Open an smerge buffer to reconcile LOCAL vs REMOTE for LABEL.
The user resolves the conflict markers with the usual `smerge-mode' commands,
then \\[pearl--conflict-commit] hands the reconciled text to ON-FINISH and kills
the buffer, while \\[pearl--conflict-abort] cancels (the local text is already
-stashed). ON-FINISH runs only when no conflict markers remain."
+stashed). ON-FINISH runs only when no conflict markers remain. ON-ABORT, when
+given, is a thunk run on abort so the save engine can report the outcome."
(require 'smerge-mode)
(let ((buf (get-buffer-create (format "*pearl-merge: %s*" label))))
(with-current-buffer buf
@@ -2123,6 +2140,7 @@ stashed). ON-FINISH runs only when no conflict markers remain."
(goto-char (point-min))
(smerge-mode 1)
(setq-local pearl--conflict-on-finish on-finish)
+ (setq-local pearl--conflict-on-abort on-abort)
(local-set-key (kbd "C-c C-c") #'pearl--conflict-commit)
(local-set-key (kbd "C-c C-k") #'pearl--conflict-abort)
(setq-local header-line-format
@@ -2144,11 +2162,15 @@ armed ON-FINISH callback and kills the buffer."
(defun pearl--conflict-abort ()
"Abort the current pearl conflict buffer without pushing.
-The local text was stashed before the buffer opened, so nothing is lost."
+The local text was stashed before the buffer opened, so nothing is lost. Runs
+the armed `pearl--conflict-on-abort' thunk (if any) so the save engine can
+report the field as left in conflict."
(interactive)
- (kill-buffer (current-buffer))
- (message "Conflict resolution aborted; your text is on the kill ring and in %s"
- pearl--conflict-backup-buffer))
+ (let ((cb pearl--conflict-on-abort))
+ (kill-buffer (current-buffer))
+ (message "Conflict resolution aborted; your text is on the kill ring and in %s"
+ pearl--conflict-backup-buffer)
+ (when cb (funcall cb))))
(defun pearl--issue-body-at-point ()
"Return the description body of the Linear issue subtree at point.
@@ -2277,137 +2299,41 @@ Callers wrap this in `save-excursion' when they must not move point."
(unless (ignore-errors (org-back-to-heading t) t)
(user-error "%s" (or message "Not on a Linear issue heading"))))
-(defun pearl--commit-sync-decision (decision spec)
- "Carry out DECISION for a gated text-field sync, per the SPEC plist.
-DECISION is a `pearl--sync-decision' result. The description, title, and
-comment syncs share this dispatch; they differ only in the SPEC values:
-
- :local local text being synced
- :remote remote text just fetched
- :marker marker at the entry whose drawer holds the hash
- :prop SHA256 provenance property name
- :label label for the conflict prompt
- :apply FN (reconciled-text) writing it into the buffer
- :push FN (text result-callback) performing the API push
- :noop-message shown on `:noop'
- :success-message shown after a successful `:push'
- :fail-message shown on a failed `:push'
- :after-push optional FN (result marker) for extra success bookkeeping
-
-The conflict path reuses `:push', adapting its result-callback to the
-success-boolean `pearl--resolve-conflict' expects."
- (let ((push (plist-get spec :push))
- (marker (plist-get spec :marker)))
- (pcase decision
- (:noop (message "%s" (plist-get spec :noop-message)))
- (:conflict
- (pearl--resolve-conflict
- (plist-get spec :label) (plist-get spec :local) (plist-get spec :remote)
- marker (plist-get spec :prop) (plist-get spec :apply)
- (lambda (text cb)
- (funcall push text (lambda (r) (funcall cb (plist-get r :success)))))))
- (:push
- (funcall push (plist-get spec :local)
- (lambda (result)
- (if (plist-get result :success)
- (progn
- (org-entry-put marker (plist-get spec :prop)
- (secure-hash 'sha256 (plist-get spec :local)))
- (when (plist-get spec :after-push)
- (funcall (plist-get spec :after-push) result marker))
- (message "%s" (plist-get spec :success-message))
- (pearl--surface-buffer (marker-buffer marker)))
- (message "%s" (plist-get spec :fail-message)))))))))
-
;;;###autoload
(defun pearl-sync-current-issue ()
"Push the description edited in the Org body of the issue at point to Linear.
-Works from anywhere inside an issue subtree. The push is gated: if the body
-is unchanged since the last fetch nothing is sent; if it was edited and the
-remote is unchanged the edit is pushed and the provenance properties advance;
-if both the body and the remote changed since the last fetch the push is
-refused and the conflict reported (refresh to reconcile)."
+Works from anywhere inside an issue subtree. A thin wrapper over the save
+engine's description saver: an unchanged body sends nothing; an edit against an
+unchanged remote is pushed and both description hashes advance; a both-changed
+case is offered conflict resolution (cancel leaves it untouched)."
(interactive)
(save-excursion
(pearl--goto-heading-or-error)
- (let ((issue-id (org-entry-get nil "LINEAR-ID"))
- (stored (org-entry-get nil "LINEAR-DESC-SHA256"))
- (marker (point-marker)))
- (unless issue-id
- (user-error "Not on a Linear issue heading"))
- (let* ((local-md (pearl--org-to-md (pearl--issue-body-at-point))))
- (if (string= (secure-hash 'sha256 local-md) (or stored ""))
- (message "No description changes to sync for %s" issue-id)
- (pearl--progress "Checking Linear for remote changes...")
- (pearl--fetch-issue-description-async
- issue-id
- (lambda (remote)
- (if (null remote)
- (message "Could not fetch %s from Linear; not syncing" issue-id)
- (pearl--commit-sync-decision
- (pearl--sync-decision local-md stored (plist-get remote :description))
- (list
- :local local-md
- :remote (plist-get remote :description)
- :marker marker
- :prop "LINEAR-DESC-SHA256"
- :label (format "%s description" issue-id)
- :apply (lambda (md)
- (org-with-point-at marker
- (pearl--set-entry-body-at-point (pearl--md-to-org md))))
- :push (lambda (text cb)
- (pearl--update-issue-description-async issue-id text cb))
- :noop-message (format "%s already matches Linear" issue-id)
- :success-message (format "Synced description for %s to Linear" issue-id)
- :fail-message (format "Failed to sync description for %s" issue-id)
- :after-push (lambda (result m)
- (when (plist-get result :updated-at)
- (org-entry-put m "LINEAR-DESC-UPDATED-AT"
- (plist-get result :updated-at))))))))))))))
+ (unless (org-entry-get nil "LINEAR-ID")
+ (user-error "Not on a Linear issue heading"))
+ (let ((buf (current-buffer)))
+ (pearl--progress "Saving description...")
+ (pearl--save-description-field
+ (point-marker)
+ (lambda (outcome) (pearl--report-save-outcome outcome buf))))))
;;;###autoload
(defun pearl-sync-current-issue-title ()
"Push the title edited in the heading of the issue at point to Linear.
-A separate path from the description sync, sharing the same conflict gate and
-working from anywhere inside an issue subtree. Note the title is lossy: the
-renderer strips square brackets, so the heading holds the stripped form and a
-push sends that stripped title. Gated like the description sync: unchanged
-title sends nothing; a local edit against an unchanged remote pushes and
-advances the title provenance; both-changed refuses and reports the conflict."
+A thin wrapper over the save engine's title saver, sharing the conflict gate
+with the description sync and working from anywhere inside an issue subtree.
+The title is lossy: the renderer strips square brackets, so the heading holds
+the stripped form and a push sends that stripped title."
(interactive)
(save-excursion
(pearl--goto-heading-or-error)
- (let ((issue-id (org-entry-get nil "LINEAR-ID"))
- (stored (org-entry-get nil "LINEAR-TITLE-SHA256"))
- (marker (point-marker)))
- (unless issue-id
- (user-error "Not on a Linear issue heading"))
- (let ((local-title (pearl--issue-title-at-point)))
- (if (string= (secure-hash 'sha256 local-title) (or stored ""))
- (message "No title changes to sync for %s" issue-id)
- (pearl--progress "Checking Linear for remote title changes...")
- (pearl--fetch-issue-title-async
- issue-id
- (lambda (remote)
- (if (null remote)
- (message "Could not fetch %s from Linear; not syncing" issue-id)
- (pearl--commit-sync-decision
- (pearl--sync-decision local-title stored (plist-get remote :title))
- (list
- :local local-title
- :remote (plist-get remote :title)
- :marker marker
- :prop "LINEAR-TITLE-SHA256"
- :label (format "%s title" issue-id)
- :apply (lambda (md)
- (org-with-point-at marker
- (org-back-to-heading t)
- (org-edit-headline md)))
- :push (lambda (text cb)
- (pearl--update-issue-title-async issue-id text cb))
- :noop-message (format "%s title already matches Linear" issue-id)
- :success-message (format "Synced title for %s to Linear" issue-id)
- :fail-message (format "Failed to sync title for %s" issue-id)))))))))))
+ (unless (org-entry-get nil "LINEAR-ID")
+ (user-error "Not on a Linear issue heading"))
+ (let ((buf (current-buffer)))
+ (pearl--progress "Saving title...")
+ (pearl--save-title-field
+ (point-marker)
+ (lambda (outcome) (pearl--report-save-outcome outcome buf))))))
(defun pearl--replace-issue-subtree-at-point (issue)
"Replace the issue subtree at point with a freshly formatted ISSUE entry.
@@ -3280,6 +3206,396 @@ bot / external comment is `:read-only' -- edited locally but not pushable."
(push c read-only)))
(list :own (nreverse own) :read-only (nreverse read-only))))
+;;; Save engine (the save model's per-field savers)
+;;
+;; Each dirty field runs through `pearl--run-field-save', which performs the
+;; fetch + the three-way `pearl--sync-decision' + the push (or conflict
+;; resolution) and invokes its callback exactly once with a structured outcome
+;; instead of messaging. The per-field savers below build the spec for a
+;; title, description, or own comment. The interactive sync commands are thin
+;; wrappers that run one saver and message its outcome; `pearl-save-issue' /
+;; `pearl-save-all' (later) drive several savers through a queue and aggregate
+;; the outcomes -- never message-scraping.
+
+(defun pearl--save-outcome-detail (label status reason)
+ "Return a human-readable detail string for LABEL at STATUS/REASON."
+ (pcase status
+ ('pushed (format "%s pushed" label))
+ ('unchanged (format "%s unchanged" label))
+ ('resolved-remote (format "%s took Linear's version" label))
+ ('conflict (format "%s left in conflict (%s)" label (or reason "unresolved")))
+ ('skipped (format "%s skipped (%s)" label (or reason "not pushable")))
+ ('failed (format "%s failed (%s)" label (or reason "error")))
+ (_ (format "%s: %s" label status))))
+
+(defun pearl--save-outcome (spec status reason)
+ "Build a save-outcome plist for SPEC with STATUS and REASON.
+SPEC is a field-save spec (see `pearl--run-field-save'). STATUS is one of
+`pushed' `unchanged' `conflict' `resolved-remote' `skipped' `failed'. REASON
+is nil or a symbol detailing a non-success outcome (`fetch-failed',
+`push-failed', `cancelled', `aborted', `read-only', `viewer-unavailable',
+`missing-property')."
+ (list :issue-id (plist-get spec :issue-id)
+ :identifier (plist-get spec :identifier)
+ :field (plist-get spec :field)
+ :comment-id (plist-get spec :comment-id)
+ :status status
+ :reason reason
+ :label (plist-get spec :label)
+ :message (pearl--save-outcome-detail (plist-get spec :label) status reason)))
+
+(defun pearl--save-field-push (spec text callback)
+ "Push TEXT for SPEC, advancing the provenance hash on success.
+CALLBACK gets a `pushed' outcome after any :after-push bookkeeping runs, or a
+`failed' / `push-failed' outcome when the update reports no success."
+ (let ((marker (plist-get spec :marker)))
+ (funcall (plist-get spec :push) text
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (org-entry-put marker (plist-get spec :prop)
+ (secure-hash 'sha256 text))
+ (when (plist-get spec :after-push)
+ (funcall (plist-get spec :after-push) result marker))
+ (funcall callback (pearl--save-outcome spec 'pushed nil)))
+ (funcall callback (pearl--save-outcome spec 'failed 'push-failed)))))))
+
+(defun pearl--run-field-save (spec callback)
+ "Save one field per SPEC, invoking CALLBACK once with a save-outcome plist.
+SPEC keys: :issue-id :identifier :field :comment-id :marker :prop :label
+:local (current text) :stored (stored hash) :fetch (FN CB, calls CB with the
+remote text or nil) :push (FN TEXT CB, calls CB with a (:success BOOL ...)
+result) :apply (FN TEXT, writes reconciled text into the buffer) :after-push
+\(optional FN RESULT MARKER).
+
+No-op fast path: when the local hash already equals :stored the field is
+`unchanged' and no fetch fires. Otherwise the remote is fetched (nil ->
+`failed' / `fetch-failed'), `pearl--sync-decision' runs, and the field is
+pushed, taken from the remote, or sent through `pearl--resolve-conflict'.
+CALLBACK fires exactly once, only after the final outcome is known -- including
+the deferred smerge path."
+ (let* ((local (or (plist-get spec :local) ""))
+ (stored (or (plist-get spec :stored) "")))
+ (if (string= (secure-hash 'sha256 local) stored)
+ (funcall callback (pearl--save-outcome spec 'unchanged nil))
+ (funcall (plist-get spec :fetch)
+ (lambda (remote)
+ (if (null remote)
+ (funcall callback (pearl--save-outcome spec 'failed 'fetch-failed))
+ (pcase (pearl--sync-decision local stored remote)
+ (:noop (funcall callback (pearl--save-outcome spec 'unchanged nil)))
+ (:push (pearl--save-field-push spec local callback))
+ (:conflict
+ (pearl--resolve-conflict
+ (plist-get spec :label) local remote
+ (plist-get spec :marker) (plist-get spec :prop)
+ (plist-get spec :apply)
+ (lambda (text cb)
+ (funcall (plist-get spec :push) text
+ (lambda (r) (funcall cb (plist-get r :success)))))
+ (lambda (status reason)
+ (funcall callback (pearl--save-outcome spec status reason))))))))))))
+
+(defun pearl--save-description-field (marker callback)
+ "Save the description of the issue subtree at MARKER, calling CALLBACK.
+A push advances both `LINEAR-DESC-SHA256' (the markdown baseline used by the
+remote conflict gate) and `LINEAR-DESC-ORG-SHA256' (the rendered-Org baseline
+used by the local dirty scan), so the next scan sees the ticket as clean."
+ (org-with-point-at marker
+ (let* ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (identifier (org-entry-get nil "LINEAR-IDENTIFIER"))
+ (local (pearl--org-to-md (pearl--issue-body-at-point)))
+ (spec (list
+ :issue-id issue-id :identifier identifier :field 'description
+ :comment-id nil :marker marker :prop "LINEAR-DESC-SHA256"
+ :local local :stored (org-entry-get nil "LINEAR-DESC-SHA256")
+ :label (format "%s description" (or identifier issue-id))
+ :fetch (lambda (cb)
+ (pearl--fetch-issue-description-async
+ issue-id
+ (lambda (remote) (funcall cb (and remote (plist-get remote :description))))))
+ :push (lambda (text cb)
+ (pearl--update-issue-description-async issue-id text cb))
+ :apply (lambda (md)
+ (org-with-point-at marker
+ (pearl--set-entry-body-at-point (pearl--md-to-org md))))
+ :after-push (lambda (result m)
+ (when (plist-get result :updated-at)
+ (org-entry-put m "LINEAR-DESC-UPDATED-AT"
+ (plist-get result :updated-at)))))))
+ (pearl--run-field-save
+ spec
+ (lambda (outcome)
+ ;; A push or use-remote leaves the buffer body current; resync the Org
+ ;; hash from it so the lossless local scan agrees with the markdown one.
+ (when (memq (plist-get outcome :status) '(pushed resolved-remote))
+ (org-with-point-at marker
+ (org-entry-put marker "LINEAR-DESC-ORG-SHA256"
+ (secure-hash 'sha256 (pearl--issue-body-at-point)))))
+ (funcall callback outcome))))))
+
+(defun pearl--save-title-field (marker callback)
+ "Save the issue title at MARKER, calling CALLBACK with the save outcome."
+ (org-with-point-at marker
+ (let* ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (identifier (org-entry-get nil "LINEAR-IDENTIFIER"))
+ (spec (list
+ :issue-id issue-id :identifier identifier :field 'title
+ :comment-id nil :marker marker :prop "LINEAR-TITLE-SHA256"
+ :local (pearl--issue-title-at-point)
+ :stored (org-entry-get nil "LINEAR-TITLE-SHA256")
+ :label (format "%s title" (or identifier issue-id))
+ :fetch (lambda (cb)
+ (pearl--fetch-issue-title-async
+ issue-id
+ (lambda (remote) (funcall cb (and remote (plist-get remote :title))))))
+ :push (lambda (text cb)
+ (pearl--update-issue-title-async issue-id text cb))
+ :apply (lambda (title)
+ (org-with-point-at marker
+ (org-back-to-heading t)
+ (org-edit-headline title))))))
+ (pearl--run-field-save spec callback))))
+
+(defun pearl--save-comment-field (marker viewer-id callback)
+ "Save the comment at MARKER, calling CALLBACK with the outcome.
+VIEWER-ID is the resolved viewer id (already known by the caller). A comment
+authored by anyone else is reported `skipped' / `read-only' with no network;
+only the viewer's own comments are saved through the gate."
+ (org-with-point-at marker
+ (let* ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID"))
+ (author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID"))
+ (spec (list
+ :issue-id (org-entry-get nil "LINEAR-ID")
+ :identifier comment-id :field 'comment :comment-id comment-id
+ :marker marker :prop "LINEAR-COMMENT-SHA256"
+ :local (pearl--org-to-md (pearl--issue-body-at-point))
+ :stored (org-entry-get nil "LINEAR-COMMENT-SHA256")
+ :label (format "comment %s" comment-id)
+ :fetch (lambda (cb) (pearl--fetch-comment-body-async comment-id cb))
+ :push (lambda (text cb) (pearl--update-comment-async comment-id text cb))
+ :apply (lambda (md)
+ (org-with-point-at marker
+ (pearl--set-entry-body-at-point (pearl--md-to-org md)))))))
+ (cond
+ ((null viewer-id)
+ (funcall callback (pearl--save-outcome spec 'skipped 'viewer-unavailable)))
+ ((not (pearl--comment-editable-p author-id viewer-id))
+ (funcall callback (pearl--save-outcome spec 'skipped 'read-only)))
+ (t (pearl--run-field-save spec callback))))))
+
+(defun pearl--report-save-outcome (outcome buffer)
+ "Message a single field-save OUTCOME and surface BUFFER on a push.
+The reporter for the thin interactive wrappers (`pearl-sync-current-issue' and
+friends), which save one field; `pearl-save-issue' / `pearl-save-all'
+aggregate their own summaries instead."
+ (message "%s" (plist-get outcome :message))
+ (when (memq (plist-get outcome :status) '(pushed resolved-remote))
+ (pearl--surface-buffer buffer)))
+
+(defun pearl--run-save-queue (thunks callback)
+ "Run THUNKS sequentially; CALLBACK gets their outcomes, in order, at the end.
+Each thunk is a function of one argument DONE, which it calls with a single
+save-outcome when finished. The next thunk starts only after the current
+thunk's DONE fires, so at most one conflict-resolution UI is ever live -- the
+deferred smerge path pauses the queue until it commits or aborts. An empty
+THUNKS list calls CALLBACK with nil immediately."
+ (let (outcomes run-next)
+ (setq run-next
+ (lambda (remaining)
+ (if (null remaining)
+ (funcall callback (nreverse outcomes))
+ (funcall (car remaining)
+ (lambda (outcome)
+ (push outcome outcomes)
+ (funcall run-next (cdr remaining)))))))
+ (funcall run-next thunks)))
+
+(defun pearl--save-summary-message (outcomes)
+ "Return a one-line summary string for OUTCOMES, grouped by status.
+A status that carries a reason (skipped / conflict / failed) shows the first
+such reason next to its count."
+ (let ((counts (make-hash-table :test 'eq))
+ (reasons (make-hash-table :test 'eq))
+ parts)
+ (dolist (o outcomes)
+ (let ((s (plist-get o :status)))
+ (puthash s (1+ (gethash s counts 0)) counts)
+ (when (and (plist-get o :reason) (not (gethash s reasons)))
+ (puthash s (plist-get o :reason) reasons))))
+ (dolist (s '(pushed resolved-remote unchanged conflict skipped failed))
+ (let ((n (gethash s counts 0)))
+ (when (> n 0)
+ (push (if (gethash s reasons)
+ (format "%d %s (%s)" n s (gethash s reasons))
+ (format "%d %s" n s))
+ parts))))
+ (if parts (string-join (nreverse parts) ", ") "nothing")))
+
+(defun pearl--report-save-summary (outcomes buffer label)
+ "Message a one-line summary of OUTCOMES under LABEL.
+Surface BUFFER when any field pushed. The shared close-out for
+`pearl-save-issue' and `pearl-save-all'."
+ (let (pushed)
+ (dolist (o outcomes)
+ (when (memq (plist-get o :status) '(pushed resolved-remote))
+ (setq pushed t)))
+ (message "%s: %s" label (pearl--save-summary-message outcomes))
+ (when pushed (pearl--surface-buffer buffer))))
+
+(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')."
+ (let (thunks)
+ (when (plist-get dirty :title)
+ (push (lambda (done) (pearl--save-title-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))
+ (let ((cm (plist-get c :marker)))
+ (push (lambda (done) (pearl--save-comment-field cm viewer-id done)) thunks)))
+ (nreverse thunks)))
+
+(defun pearl--save-issue-at (marker buffer dirty viewer-id)
+ "Queue and run the DIRTY field saves for the issue at MARKER, then summarize.
+VIEWER-ID is the resolved viewer id (or nil). BUFFER is surfaced if anything
+was pushed. The summary is labelled with the issue identifier."
+ (let ((label (org-with-point-at marker
+ (or (org-entry-get nil "LINEAR-IDENTIFIER")
+ (org-entry-get nil "LINEAR-ID")))))
+ (pearl--run-save-queue
+ (pearl--save-field-thunks marker dirty viewer-id)
+ (lambda (outcomes)
+ (pearl--report-save-summary outcomes buffer (format "Saved %s" label))))))
+
+;;;###autoload
+(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."
+ (interactive)
+ (save-excursion
+ (pearl--goto-heading-or-error)
+ (unless (org-entry-get nil "LINEAR-ID")
+ (user-error "Not on a Linear issue heading"))
+ (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)))
+ (message "Nothing to save for %s"
+ (or (org-entry-get nil "LINEAR-IDENTIFIER")
+ (org-entry-get nil "LINEAR-ID")))
+ (if (plist-get dirty :comment-candidates)
+ (pearl--viewer-async
+ (lambda (viewer)
+ (pearl--save-issue-at marker buffer dirty
+ (and viewer (plist-get viewer :id)))))
+ (pearl--save-issue-at marker buffer dirty nil))))))
+
+(defun pearl--scan-all-dirty ()
+ "Scan every issue subtree in the buffer; return ((MARKER . DIRTY) ...).
+One entry per issue with any locally-changed field, in buffer order. No
+network. This snapshots the work list at scan time -- edits made afterward
+wait for the next save (each field's content is still re-read at push)."
+ (let (result)
+ (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))
+ (push (cons marker dirty) result))))
+ (nreverse result)))
+
+(defun pearl--save-all-counts (scan viewer-id)
+ "Tally the dirty fields in SCAN, classifying comments with VIEWER-ID.
+Returns a plist (:issues :titles :descriptions :own-comments
+:read-only-comments)."
+ (let ((issues 0) (titles 0) (descriptions 0) (own 0) (read-only 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)))
+ (let ((split (pearl--classify-comment-candidates
+ (plist-get dirty :comment-candidates) viewer-id)))
+ (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
+ :own-comments own :read-only-comments read-only)))
+
+(defun pearl--save-all-prompt (counts)
+ "Return the one-time confirmation prompt string for the dirty COUNTS plist."
+ (let* ((titles (plist-get counts :titles))
+ (descriptions (plist-get counts :descriptions))
+ (own (plist-get counts :own-comments))
+ (read-only (plist-get counts :read-only-comments))
+ (fields (+ titles descriptions own))
+ (issues (plist-get counts :issues))
+ parts)
+ (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 (> own 0)
+ (push (format "%d comment%s" own (if (= own 1) "" "s")) parts))
+ (format "Save %d field%s across %d issue%s? (%s%s) "
+ fields (if (= fields 1) "" "s")
+ issues (if (= issues 1) "" "s")
+ (if parts (string-join (nreverse parts) ", ") "0 fields")
+ (if (> read-only 0)
+ (format "; %d read-only comment%s skipped"
+ read-only (if (= read-only 1) "" "s"))
+ ""))))
+
+(defun pearl--save-all-run (scan buffer viewer-id)
+ "Confirm SCAN once, then save all its dirty fields and report.
+VIEWER-ID gates comment ownership; BUFFER is surfaced if anything pushed.
+Declining the prompt mutates nothing. The queue continues past a per-field
+conflict, so one conflicted ticket does not stop the rest."
+ (if (not (y-or-n-p (pearl--save-all-prompt (pearl--save-all-counts scan viewer-id))))
+ (message "save-all cancelled; nothing saved")
+ (let (thunks)
+ (dolist (cell scan)
+ (setq thunks (append thunks
+ (pearl--save-field-thunks (car cell) (cdr cell) viewer-id))))
+ (pearl--run-save-queue
+ thunks
+ (lambda (outcomes)
+ (pearl--report-save-summary outcomes buffer "Saved all"))))))
+
+;;;###autoload
+(defun pearl-save-all ()
+ "Save every changed field across all Linear issues in the file, after one prompt.
+A local dirty scan (no network for title/description; comment changed-candidates
+found locally) builds the work list. When comment candidates exist and the
+viewer cache is cold, one read-only viewer lookup runs so read-only comments are
+counted -- no mutation before the prompt. A single confirmation names the field
+counts; declining mutates nothing. On confirm the dirty fields save
+sequentially, continuing past any per-field conflict, and one summary is
+reported. A clean file does nothing."
+ (interactive)
+ (let* ((buffer (current-buffer))
+ (scan (pearl--scan-all-dirty)))
+ (if (null scan)
+ (message "Nothing to save")
+ (let ((has-comments nil))
+ (dolist (cell scan)
+ (when (plist-get (cdr cell) :comment-candidates) (setq has-comments t)))
+ (if (and has-comments (not pearl--cache-viewer))
+ (pearl--viewer-async
+ (lambda (viewer)
+ (pearl--save-all-run scan buffer (and viewer (plist-get viewer :id)))))
+ (pearl--save-all-run scan buffer
+ (and pearl--cache-viewer
+ (plist-get pearl--cache-viewer :id))))))))
+
(defun pearl--merge-issues-into-buffer (issues)
"Merge normalized ISSUES into the current buffer by `LINEAR-ID'.
Same-source refresh semantics: an existing issue still in ISSUES is re-rendered
@@ -3801,47 +4117,23 @@ reported (refresh to reconcile)."
(save-excursion
(pearl--goto-heading-or-error "Not on a Linear comment")
(let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID"))
- (author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID"))
(stored (org-entry-get nil "LINEAR-COMMENT-SHA256"))
- (marker (point-marker)))
+ (marker (point-marker))
+ (buf (current-buffer)))
(unless comment-id
(user-error "Not on a Linear comment"))
- ;; `pearl--issue-body-at-point' reads the text after the drawer and before
- ;; the first child heading -- a comment subtree has that same shape, so the
- ;; description path's body reader serves the comment body unchanged.
+ ;; No-op fast path before resolving the viewer: an unchanged comment
+ ;; costs no viewer lookup. `pearl--issue-body-at-point' reads the text
+ ;; after the drawer and before the first child heading -- a comment
+ ;; subtree has that shape, so it serves the comment body unchanged.
(let ((local-md (pearl--org-to-md (pearl--issue-body-at-point))))
(if (string= (secure-hash 'sha256 local-md) (or stored ""))
(message "No comment changes to sync")
(pearl--viewer-async
(lambda (viewer)
- (cond
- ((null viewer)
- (message "Could not determine your Linear identity; not editing"))
- ((not (pearl--comment-editable-p author-id (plist-get viewer :id)))
- (message "You can only edit your own comments"))
- (t
- (pearl--progress "Checking Linear for remote changes...")
- (pearl--fetch-comment-body-async
- comment-id
- (lambda (remote-md)
- (if (null remote-md)
- (message "Could not fetch the comment from Linear; not syncing")
- (pearl--commit-sync-decision
- (pearl--sync-decision local-md stored remote-md)
- (list
- :local local-md
- :remote remote-md
- :marker marker
- :prop "LINEAR-COMMENT-SHA256"
- :label "comment"
- :apply (lambda (md)
- (org-with-point-at marker
- (pearl--set-entry-body-at-point (pearl--md-to-org md))))
- :push (lambda (text cb)
- (pearl--update-comment-async comment-id text cb))
- :noop-message "Comment already matches Linear"
- :success-message "Synced comment to Linear"
- :fail-message "Failed to sync comment"))))))))))))))
+ (pearl--save-comment-field
+ marker (and viewer (plist-get viewer :id))
+ (lambda (outcome) (pearl--report-save-outcome outcome buf))))))))))
;;; Transient Menu