diff options
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 149 |
1 files changed, 101 insertions, 48 deletions
@@ -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. |
