aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-26 00:31:44 -0500
committerCraig Jennings <c@cjennings.net>2026-05-26 00:31:44 -0500
commit8c42615b09471db089d81b7d702c60917fb85c79 (patch)
tree34b847a3e92658ad5c6d1cc6f566ff6622c14495
parent5ee0bdcc4a1bd4b935973be7201b20380ec8cb80 (diff)
downloadpearl-8c42615b09471db089d81b7d702c60917fb85c79.tar.gz
pearl-8c42615b09471db089d81b7d702c60917fb85c79.zip
feat: resolve a cycled TODO keyword to its Linear state at save
Cycling a ticket's TODO keyword (C-c C-t) now counts as a state edit. The dirty scan gains a keyword-cycle arm: state is dirty when the explicit id moved off its baseline (the picker arm, as before) or when the keyword diverged from the synced state name's slug. At save, the picker arm pushes the explicit id; the keyword-cycle arm resolves the keyword to a team state id by slug match, first by position, and pushes that. A keyword no team state matches is reported skipped rather than guessed. set-heading-state now slugifies the keyword instead of the static-map lookup, so the picker writes the same keyword the renderer derives. Without that, a picker edit to a non-standard state would read as permanently cycled. That retires pearl--map-linear-state-to-org, whose only caller this was, and its tests.
-rw-r--r--README.org15
-rw-r--r--pearl.el149
-rw-r--r--tests/test-pearl-mapping.el28
-rw-r--r--tests/test-pearl-save.el75
-rw-r--r--tests/test-pearl-todo-keywords.el22
5 files changed, 209 insertions, 80 deletions
diff --git a/README.org b/README.org
index 1ae0131..860d41a 100644
--- a/README.org
+++ b/README.org
@@ -221,19 +221,12 @@ Only comments you authored are editable or deletable. Pearl refuses edits and de
*** State and the TODO keyword
-=pearl-state-to-todo-mapping= maps Linear state names to Org TODO keywords, so a fetched issue renders with a keyword you recognize:
+A fetched issue renders with a TODO keyword derived from its Linear state name: the name is slugified (upcased, non-alphanumeric runs collapsed to hyphens), so "In Progress" becomes =IN-PROGRESS= and "Dev Review" becomes =DEV-REVIEW=. The buffer's =#+TODO= line is built from the real workflow states of the teams on display, so every keyword you see is a state that team actually has, partitioned active / done by Linear's state type.
-#+begin_src emacs-lisp
- (setq pearl-state-to-todo-mapping
- '(("Todo" . "TODO")
- ("In Progress" . "IN-PROGRESS")
- ("In Review" . "IN-REVIEW")
- ("Backlog" . "BACKLOG")
- ("Blocked" . "BLOCKED")
- ("Done" . "DONE")))
-#+end_src
+You change a ticket's state two ways, both reconciled and pushed at the next save:
-Make sure your =org-todo-keywords= include every keyword you map to. Change a ticket's state with =pearl-edit-state=, which reaches every state on the team -- including ones the keyword set doesn't cover. (Cycling the keyword to drive the state change directly, and deriving the keyword set from the team's real states, are planned follow-ups.)
+- Cycle the keyword with =C-c C-t= (or =S-<right>= / =S-<left>=). The keyword you cycle to is matched back to a team state by the same slug rule, so cycling =TODO= → =IN-PROGRESS= moves the ticket to the "In Progress" state. A keyword no team state slugifies to can't be resolved, so that save is reported skipped.
+- =pearl-edit-state= completes over every state on the team and writes the keyword for you. Use it to reach a state whose keyword you don't remember, or one the visible keyword set doesn't cover.
** The Org File
:PROPERTIES:
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.
diff --git a/tests/test-pearl-mapping.el b/tests/test-pearl-mapping.el
index 2a96163..b5faa3d 100644
--- a/tests/test-pearl-mapping.el
+++ b/tests/test-pearl-mapping.el
@@ -37,27 +37,13 @@
(pearl--todo-states-pattern-source nil))
,@body))
-;;; pearl--map-linear-state-to-org
-
-(ert-deftest test-pearl-map-linear-state-to-org-mapped-returns-keyword ()
- "A Linear state present in the mapping returns its org keyword."
- (test-pearl--with-mapping '(("Todo" . "TODO") ("In Progress" . "DOING"))
- (should (string-equal "DOING" (pearl--map-linear-state-to-org "In Progress")))))
-
-(ert-deftest test-pearl-map-linear-state-to-org-unmapped-defaults-todo ()
- "An unmapped Linear state falls back to TODO."
- (test-pearl--with-mapping '(("Todo" . "TODO") ("Done" . "DONE"))
- (should (string-equal "TODO" (pearl--map-linear-state-to-org "Triage")))))
-
-(ert-deftest test-pearl-map-linear-state-to-org-nil-defaults-todo ()
- "A nil state falls back to TODO rather than erroring."
- (test-pearl--with-mapping '(("Todo" . "TODO"))
- (should (string-equal "TODO" (pearl--map-linear-state-to-org nil)))))
-
-;; pearl--map-org-state-to-linear (the keyword->state reverse map) was removed
-;; with the org-sync push path in save-model-v2; the keyword-cycle save will
-;; resolve through the team's states (the keyword-derivation task), not a flat
-;; reverse mapping.
+;; pearl--map-linear-state-to-org (the static name->keyword lookup) was removed
+;; in the keyword-cycle save: the keyword is now always the slug of the state
+;; name (`pearl--state-name-to-keyword'), matching the workspace-derived #+TODO
+;; header, and the keyword->state-id direction resolves through the team's
+;; states (`pearl--resolve-keyword-to-state'), not a flat mapping. The mapping
+;; alist itself survives only as the source for `pearl--get-todo-states-pattern'
+;; (tested below).
;;; pearl--get-todo-states-pattern
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
index 1f4f273..22df84e 100644
--- a/tests/test-pearl-save.el
+++ b/tests/test-pearl-save.el
@@ -153,6 +153,21 @@
(org-entry-put nil "LINEAR-STATE-ID" "s2")
(should (plist-get (pearl--issue-dirty-fields) :state))))
+(ert-deftest test-pearl-dirty-fields-state-keyword-cycle ()
+ "Cycling the TODO keyword away from the synced state-name slug marks state dirty,
+even when the explicit state id still matches its baseline."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (let ((org-after-todo-state-change-hook nil))
+ (org-todo "DONE")) ; TODO -> DONE, id untouched
+ (should (plist-get (pearl--issue-dirty-fields) :state))))
+
+(ert-deftest test-pearl-dirty-fields-state-fresh-keyword-not-dirty ()
+ "A freshly rendered state (keyword equals the state-name slug) is not state dirty."
+ (test-pearl-save--in-rendered (test-pearl-save--structured-issue)
+ (org-back-to-heading t)
+ (should-not (plist-get (pearl--issue-dirty-fields) :state))))
+
(ert-deftest test-pearl-dirty-fields-assignee-change ()
"An assignee id different from the synced baseline marks assignee dirty."
(test-pearl-save--in-rendered (test-pearl-save--structured-issue)
@@ -495,6 +510,66 @@ skipped (the remote push already landed), not signal."
(should (string= "s2" pushed))
(should (string= "s2" (org-entry-get marker "LINEAR-STATE-ID-SYNCED")))))))
+(ert-deftest test-pearl-save-state-keyword-cycle-resolves-and-pushes ()
+ "A keyword-cycled state (id unmoved) resolves the keyword to its team state id
+by slug match, pushes that id, and writes the resolved name/id/baseline."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-1" :title "t" :priority 2
+ :state (:id "s1" :name "Todo") :description "body")
+ (goto-char (point-min))
+ (insert "#+TODO: TODO IN-PROGRESS | DONE\n")
+ (org-mode) ; re-init so the keywords take
+ (goto-char (point-min))
+ (re-search-forward "ENG-1")
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-TEAM-ID" "team-1")
+ (progn
+ (let ((org-after-todo-state-change-hook nil))
+ (org-todo "IN-PROGRESS")) ; cycle keyword, leave id = s1
+ (let ((marker (point-marker)) outcome pushed)
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s1") (name . "Todo") (type . "unstarted") (position . 1.0))
+ ((id . "s2") (name . "In Progress") (type . "started") (position . 2.0)))))
+ ((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb '((state (id . "s1") (name . "Todo"))))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb)
+ (setq pushed (cdr (assoc "stateId" input)))
+ (funcall cb '(:success t)))))
+ (pearl--save-state-field marker (lambda (o) (setq outcome o)))
+ (should (eq 'pushed (plist-get outcome :status)))
+ (should (string= "s2" pushed))
+ (should (string= "s2" (org-entry-get marker "LINEAR-STATE-ID")))
+ (should (string= "In Progress" (org-entry-get marker "LINEAR-STATE-NAME")))
+ (should (string= "s2" (org-entry-get marker "LINEAR-STATE-ID-SYNCED"))))))))
+
+(ert-deftest test-pearl-save-state-keyword-cycle-unknown-keyword-skips ()
+ "A keyword no team state slugifies to cannot be resolved, so the save is skipped
+with no push."
+ (test-pearl-save--in-rendered
+ '(:id "u" :identifier "ENG-1" :title "t" :priority 2
+ :state (:id "s1" :name "Todo") :description "body")
+ (goto-char (point-min))
+ (insert "#+TODO: TODO BLOCKED | DONE\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "ENG-1")
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-TEAM-ID" "team-1")
+ (progn
+ (let ((org-after-todo-state-change-hook nil))
+ (org-todo "BLOCKED")) ; no team state slugifies to BLOCKED
+ (let ((marker (point-marker)) outcome (pushed :none))
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s1") (name . "Todo") (type . "unstarted") (position . 1.0)))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id _input cb) (setq pushed :called) (funcall cb '(:success t)))))
+ (pearl--save-state-field marker (lambda (o) (setq outcome o)))
+ (should (eq 'skipped (plist-get outcome :status)))
+ (should (eq :none pushed)))))))
+
(ert-deftest test-pearl-save-labels-clean-push-advances-baseline ()
"A changed label set with an unmoved remote pushes labelIds and advances the baseline."
(test-pearl-save--in-rendered
diff --git a/tests/test-pearl-todo-keywords.el b/tests/test-pearl-todo-keywords.el
index 281beea..0555a4e 100644
--- a/tests/test-pearl-todo-keywords.el
+++ b/tests/test-pearl-todo-keywords.el
@@ -150,5 +150,27 @@
;; SHIPPED is an org done-keyword, so it lands on the done side
(should (re-search-forward "^#\\+TODO: *\\| SHIPPED$" nil t)))))
+;;; --resolve-keyword-to-state (keyword -> team state id, save-time)
+
+(ert-deftest test-pearl-resolve-keyword-to-state-collision-first-by-position ()
+ "When two state names slugify to the same keyword, the lower position wins."
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s-hi") (name . "Dev Review") (type . "started") (position . 5.0))
+ ((id . "s-lo") (name . "Dev-Review") (type . "started") (position . 2.0))))))
+ (let ((r (pearl--resolve-keyword-to-state "DEV-REVIEW" "t1")))
+ (should (string= "s-lo" (plist-get r :id)))
+ (should (string= "Dev-Review" (plist-get r :name)))
+ (should (string= "started" (plist-get r :type))))))
+
+(ert-deftest test-pearl-resolve-keyword-to-state-no-match-or-empty-returns-nil ()
+ "A keyword no state slugifies to yields nil, as does an empty/failed fetch."
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_tid)
+ '(((id . "s1") (name . "Todo") (type . "unstarted") (position . 1.0))))))
+ (should-not (pearl--resolve-keyword-to-state "BLOCKED" "t1")))
+ (cl-letf (((symbol-function 'pearl--team-states) (lambda (_tid) nil)))
+ (should-not (pearl--resolve-keyword-to-state "TODO" "t1"))))
+
(provide 'test-pearl-todo-keywords)
;;; test-pearl-todo-keywords.el ends here