aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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