aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el149
1 files changed, 101 insertions, 48 deletions
diff --git a/pearl.el b/pearl.el
index c61180c..20e4936 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1530,12 +1530,6 @@ there is no network lookup here."
;;; Mapping Functions
-(defun pearl--map-linear-state-to-org (state)
- "Map Linear STATE name to an Org TODO keyword string.
-STATE is the Linear state string."
- (or (cdr (assoc state pearl-state-to-todo-mapping))
- "TODO")) ; Default fallback
-
(defun pearl--state-name-to-keyword (name)
"Slugify a Linear state NAME to an Org TODO keyword.
Upcased, each run of non-alphanumerics replaced by a single hyphen (alnum is
@@ -2670,16 +2664,17 @@ locally so #D is accepted regardless of the user's `org-priority' settings."
(defun pearl--set-heading-state (state-name state-id)
"Update the heading at point to STATE-NAME / STATE-ID.
-Rewrites the TODO keyword (mapped from STATE-NAME) and the LINEAR-STATE-NAME /
-LINEAR-STATE-ID drawer properties. `org-after-todo-state-change-hook' is bound
-to nil during the keyword change so programmatically setting the keyword does
-not fire any todo-change hooks the user has installed."
+Rewrites the TODO keyword (the slug of STATE-NAME, matching the derived
+`#+TODO' header) and the LINEAR-STATE-NAME / LINEAR-STATE-ID drawer properties.
+`org-after-todo-state-change-hook' is bound to nil during the keyword change so
+programmatically setting the keyword does not fire any todo-change hooks the
+user has installed."
(save-excursion
(org-back-to-heading t)
(org-entry-put nil "LINEAR-STATE-NAME" state-name)
(org-entry-put nil "LINEAR-STATE-ID" state-id)
(let ((org-after-todo-state-change-hook nil))
- (org-todo (pearl--map-linear-state-to-org state-name)))))
+ (org-todo (pearl--state-name-to-keyword state-name)))))
;;;###autoload
(defun pearl-edit-state (state-name)
@@ -3251,15 +3246,45 @@ not dirty."
#'string<)
(sort (split-string base " " t) #'string<))))))
+(defun pearl--state-keyword-cycled-p ()
+ "Non-nil when the heading's TODO keyword no longer matches the synced state.
+Network-free: compares `org-get-todo-state' against the slug of
+`LINEAR-STATE-NAME' (`pearl--state-name-to-keyword'). A heading with no
+keyword or no recorded state name can't be judged cycled, so it returns nil.
+The concrete state id the keyword resolves to is determined in the saver
+(`pearl--resolve-keyword-to-state'), not here -- this scan stays local."
+ (let ((kw (org-get-todo-state))
+ (name (org-entry-get nil "LINEAR-STATE-NAME")))
+ (and kw name (not (string-empty-p name))
+ (not (string= kw (pearl--state-name-to-keyword name))))))
+
+(defun pearl--resolve-keyword-to-state (keyword team-id)
+ "Resolve KEYWORD to a TEAM-ID workflow state by slug match.
+Returns a plist (:id :name :type) for the first state -- by `position' -- whose
+name slugifies to KEYWORD, or nil when none match (a stale or hand-typed
+keyword). Uses the cached team states; a failed or empty fetch yields nil."
+ (let (best best-pos)
+ (dolist (node (ignore-errors (pearl--team-states team-id)))
+ (when (string= keyword (pearl--state-name-to-keyword (cdr (assoc 'name node))))
+ (let ((pos (or (cdr (assoc 'position node)) most-positive-fixnum)))
+ (when (or (null best) (< pos best-pos))
+ (setq best (list :id (cdr (assoc 'id node))
+ :name (cdr (assoc 'name node))
+ :type (cdr (assoc 'type node)))
+ best-pos pos)))))
+ best))
+
(defun pearl--issue-dirty-fields ()
"Return the locally-changed fields of the issue subtree at point, no network.
A plist: `:title', `:description', `:priority', `:state', `:assignee', and
`:labels' are booleans; `:comment-candidates' is the list from
`pearl--changed-comment-candidates'. The structured-field checks compare each
live value against its synced baseline (id/scalar only, no name resolution or
-remote read). `:state' here is the picker arm only -- explicit
-`LINEAR-STATE-ID' vs `LINEAR-STATE-ID-SYNCED'; the keyword-cycle arm is gated
-on keyword derivation (a later task). This is Phase A of the two-phase save
+remote read). `:state' has two arms: the picker arm (explicit
+`LINEAR-STATE-ID' vs `LINEAR-STATE-ID-SYNCED') and the keyword-cycle arm
+(`pearl--state-keyword-cycled-p' -- the keyword slug diverged from
+`LINEAR-STATE-NAME'); either marks state dirty. This is Phase A of the
+two-phase save
scan -- comment ownership is classified separately once the viewer id is known
(see `pearl--classify-comment-candidates')."
(save-excursion
@@ -3268,7 +3293,8 @@ scan -- comment ownership is classified separately once the viewer id is known
(or (org-entry-get nil "LINEAR-TITLE-SHA256") "")))
:description (pearl--subtree-dirty-p)
:priority (pearl--priority-dirty-p)
- :state (pearl--id-baseline-dirty-p "LINEAR-STATE-ID" "LINEAR-STATE-ID-SYNCED")
+ :state (or (pearl--id-baseline-dirty-p "LINEAR-STATE-ID" "LINEAR-STATE-ID-SYNCED")
+ (pearl--state-keyword-cycled-p))
:assignee (pearl--id-baseline-dirty-p "LINEAR-ASSIGNEE-ID" "LINEAR-ASSIGNEE-ID-SYNCED")
:labels (pearl--label-ids-dirty-p)
:comment-candidates (pearl--changed-comment-candidates))))
@@ -3716,42 +3742,69 @@ The live value is the heading cookie's number, diffed against `LINEAR-PRIORITY'.
(defun pearl--save-state-field (marker callback)
"Save the workflow state of the issue subtree at MARKER, calling CALLBACK.
-The picker arm: the explicit `LINEAR-STATE-ID' is diffed against
-`LINEAR-STATE-ID-SYNCED'. A push sends `stateId'."
+Two arms reach the live state id. Picker arm: the explicit `LINEAR-STATE-ID'
+moved off `LINEAR-STATE-ID-SYNCED', and that id is the live value.
+Keyword-cycle arm: the id is unmoved but the TODO keyword diverged from
+`LINEAR-STATE-NAME', so the keyword resolves to a team state id by slug match
+\(`pearl--resolve-keyword-to-state'). A keyword no team state matches can't be
+resolved, so the save is skipped. A push sends `stateId'."
(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-STATE-ID") ""))
- (remote-node nil)
- (spec
- (list
- :issue-id issue-id :identifier identifier :field 'state
- :marker marker :label (format "%s state" (or identifier issue-id))
- :live live
- :baseline (org-entry-get nil "LINEAR-STATE-ID-SYNCED")
- :equal #'string=
- :live-display (or (org-entry-get nil "LINEAR-STATE-NAME") "")
- :remote-display (lambda (_id) (pearl--node-state-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-state-id node))))))
- :push
- (lambda (cb)
- (pearl--update-issue-async issue-id (list (cons "stateId" live)) cb))
- :commit-local
- (lambda () (org-entry-put marker "LINEAR-STATE-ID-SYNCED" live))
- :commit-remote
- (lambda (remote-id)
- (org-with-point-at marker
- (pearl--set-heading-state (pearl--node-state-name remote-node) remote-id))
- (org-entry-put marker "LINEAR-STATE-ID-SYNCED" remote-id)))))
- (pearl--run-atomic-field-save spec callback))))
+ (label (format "%s state" (or identifier issue-id)))
+ (explicit-id (or (org-entry-get nil "LINEAR-STATE-ID") ""))
+ (synced (org-entry-get nil "LINEAR-STATE-ID-SYNCED"))
+ (name (or (org-entry-get nil "LINEAR-STATE-NAME") ""))
+ (picker-dirty (pearl--id-baseline-dirty-p
+ "LINEAR-STATE-ID" "LINEAR-STATE-ID-SYNCED"))
+ (cycled (and (not picker-dirty) (pearl--state-keyword-cycled-p)))
+ (resolved (and cycled
+ (pearl--resolve-keyword-to-state
+ (org-get-todo-state)
+ (org-entry-get nil "LINEAR-TEAM-ID"))))
+ (live (if cycled (and resolved (plist-get resolved :id)) explicit-id))
+ (live-name (if cycled (and resolved (plist-get resolved :name)) name))
+ (remote-node nil))
+ (if (and cycled (not resolved))
+ ;; The keyword was cycled to one no team state slugifies to -- there's
+ ;; no id to push. Report it skipped rather than guessing.
+ (funcall callback
+ (pearl--save-outcome
+ (list :issue-id issue-id :identifier identifier
+ :field 'state :label label)
+ 'skipped 'unknown-keyword))
+ (let ((spec
+ (list
+ :issue-id issue-id :identifier identifier :field 'state
+ :marker marker :label label
+ :live live
+ :baseline synced
+ :equal #'string=
+ :live-display live-name
+ :remote-display (lambda (_id) (pearl--node-state-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-state-id node))))))
+ :push
+ (lambda (cb)
+ (pearl--update-issue-async issue-id (list (cons "stateId" live)) cb))
+ :commit-local
+ (lambda ()
+ (org-with-point-at marker
+ (pearl--set-heading-state live-name live))
+ (org-entry-put marker "LINEAR-STATE-ID-SYNCED" live))
+ :commit-remote
+ (lambda (remote-id)
+ (org-with-point-at marker
+ (pearl--set-heading-state (pearl--node-state-name remote-node) remote-id))
+ (org-entry-put marker "LINEAR-STATE-ID-SYNCED" remote-id)))))
+ (pearl--run-atomic-field-save spec callback))))))
(defun pearl--save-labels-field (marker callback)
"Save the labels of the issue subtree at MARKER, calling CALLBACK.