diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-25 22:30:49 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-25 22:30:49 -0500 |
| commit | 504133e600a197b50f05d3916264433ee9e4cb12 (patch) | |
| tree | af78b8d1eae61ba82b71aec53269c7fa93fb0c3b | |
| parent | 768c9c0122130d44e9243f22f7681677623b713a (diff) | |
| download | pearl-504133e600a197b50f05d3916264433ee9e4cb12.tar.gz pearl-504133e600a197b50f05d3916264433ee9e4cb12.zip | |
feat(render): derive the #+TODO line and keywords from team workflow states
The renderer wrote a fixed #+TODO line and mapped state names through a static defcustom, so a workspace's real states (Dev Review, PM Acceptance, ...) never showed up. Now the displayed issues' teams drive both.
pearl--gather-header-states collects each team's full state set (cached via pearl--team-states, teams in first-seen order, states by position) plus every displayed issue's own state, so a failed team fetch still leaves its issues' states declared. pearl--build-org-content takes that list and derives the #+TODO via pearl--derive-todo-line. pearl--format-issue-as-org-entry renders each keyword with pearl--state-name-to-keyword instead of the static map -- slugify reproduces the old defaults, so standard states are unchanged -- and writes a :LINEAR-STATE-TYPE: drawer so a later merge scan can classify a retained heading. The team-states query gains type and position.
The integration test stubs pearl--team-states so the render stays network-free. The merge-refresh header rebuild is the next commit. Until then a same-source refresh keeps the previous header.
| -rw-r--r-- | pearl.el | 62 | ||||
| -rw-r--r-- | tests/test-integration-acceptance.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-todo-keywords.el | 38 |
3 files changed, 94 insertions, 10 deletions
@@ -1213,6 +1213,8 @@ trip on every one. Clear the cache to force a refresh." nodes { id name + type + position } } } @@ -1820,7 +1822,7 @@ identifier prefix -- so an unedited heading is a no-op on title sync)." (team (plist-get issue :team)) (project (plist-get issue :project)) (assignee (plist-get issue :assignee)) - (todo (pearl--map-linear-state-to-org (plist-get state :name))) + (todo (pearl--state-name-to-keyword (plist-get state :name))) (priority (pearl--map-linear-priority-to-org (plist-get issue :priority))) (display-title (pearl--heading-title issue)) (heading-title (pearl--heading-with-identifier @@ -1848,6 +1850,7 @@ identifier prefix -- so an unedited heading is a no-op on title sync)." (format ":LINEAR-STATE-ID: %s\n" (or (plist-get state :id) "")) (format ":LINEAR-STATE-ID-SYNCED: %s\n" (or (plist-get state :id) "")) (format ":LINEAR-STATE-NAME: %s\n" (or (plist-get state :name) "")) + (format ":LINEAR-STATE-TYPE: %s\n" (or (plist-get state :type) "")) (format ":LINEAR-ASSIGNEE-ID: %s\n" (or (plist-get assignee :id) "")) (format ":LINEAR-ASSIGNEE-ID-SYNCED: %s\n" (or (plist-get assignee :id) "")) (format ":LINEAR-ASSIGNEE-NAME: %s\n" (or (plist-get assignee :name) "")) @@ -2800,11 +2803,53 @@ writes." (when (re-search-forward "^#\\+LINEAR-SOURCE: \\(.*\\)$" nil t) (ignore-errors (car (read-from-string (match-string 1))))))) -(defun pearl--build-org-content (issues &optional source truncated) +(defun pearl--gather-header-states (issues) + "Collect the ordered workflow states for the ISSUES' teams, for the header. +Teams in first-seen order across ISSUES; within a team, states by Linear +`position'. Each team's full state set is fetched (cached) via +`pearl--team-states'. A team whose fetch fails is skipped, but every displayed +issue's own state is appended afterward so it is still declared (the coverage +guarantee). Returns a list of (:name :type :position) plists; +`pearl--derive-todo-line' de-duplicates by slug, so the appended issue states +only add the ones a failed team fetch left out." + (let ((team-ids '()) (seen (make-hash-table :test 'equal))) + (dolist (issue issues) + (let ((tid (plist-get (plist-get issue :team) :id))) + (when (and tid (not (gethash tid seen))) + (puthash tid t seen) + (push tid team-ids)))) + (let ((team-states + (apply #'append + (mapcar + (lambda (tid) + (mapcar + (lambda (node) + (list :name (cdr (assoc 'name node)) + :type (cdr (assoc 'type node)) + :position (cdr (assoc 'position node)))) + (sort (copy-sequence (or (ignore-errors (pearl--team-states tid)) '())) + (lambda (a b) + (< (or (cdr (assoc 'position a)) most-positive-fixnum) + (or (cdr (assoc 'position b)) most-positive-fixnum)))))) + (nreverse team-ids)))) + (issue-states + (delq nil + (mapcar (lambda (issue) + (let ((st (plist-get issue :state))) + (when (plist-get st :name) + (list :name (plist-get st :name) + :type (plist-get st :type) + :position (plist-get st :position))))) + issues)))) + (append team-states issue-states)))) + +(defun pearl--build-org-content (issues &optional source truncated states) "Build the Org content string for the linear org file from ISSUES. SOURCE is the active-source descriptor recorded in the header so a later -`pearl-refresh-current-view' can re-run it; TRUNCATED marks that the -page cap was hit. Pure function, no side effects." +`pearl-refresh-current-view' can re-run it; TRUNCATED marks that the page cap +was hit. STATES is the ordered workflow-state list (see +`pearl--gather-header-states') the `#+TODO:' line is derived from; a nil STATES +yields the hardcoded default. Pure function, no side effects." (let* ((src (or source '(:type filter :name "Linear issues" :filter nil))) (name (pearl--source-name src)) (filter (plist-get src :filter))) @@ -2812,11 +2857,7 @@ page cap was hit. Pure function, no side effects." (insert (format "#+title: Linear — %s\n" (if pearl-title-case-headings (pearl--title-case name) name))) (insert "#+STARTUP: show3levels\n") - (insert (format "#+TODO: %s\n" - (if (bound-and-true-p org-todo-keywords) - (let ((seq (car org-todo-keywords))) - (mapconcat #'identity (cdr seq) " ")) - "TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE"))) + (insert (format "#+TODO: %s\n" (pearl--derive-todo-line states))) ;; Source-tracking metadata: the serialized source drives refresh; the ;; rest is human-readable provenance. (insert (format "#+LINEAR-SOURCE: %s\n" (prin1-to-string src))) @@ -2860,7 +2901,8 @@ In every case the resulting buffer is surfaced (see `pearl--surface-buffer'), so a fetch run from a menu or dashboard actually shows the issues instead of just writing the file and leaving it off-screen." (let* ((org-file-path pearl-org-file-path) - (new-content (pearl--build-org-content issues source truncated)) + (states (pearl--gather-header-states issues)) + (new-content (pearl--build-org-content issues source truncated states)) (existing-buf (find-buffer-visiting org-file-path))) (cond ;; Branch A: no buffer visits the file -- atomic file write, then visit. diff --git a/tests/test-integration-acceptance.el b/tests/test-integration-acceptance.el index de5f0e7..11b88b2 100644 --- a/tests/test-integration-acceptance.el +++ b/tests/test-integration-acceptance.el @@ -109,6 +109,10 @@ because the description-update mutation contains both `IssueDescription' and (when (find-buffer-visiting tmp) (kill-buffer (find-buffer-visiting tmp))) (cl-letf (((symbol-function 'pearl--graphql-request-async) #'test-integration--dispatch) + ;; the header-state gather fetches team states synchronously; + ;; stub it so the render stays network-free (the derived #+TODO + ;; then comes from the displayed issues' own states) + ((symbol-function 'pearl--team-states) (lambda (_team-id) nil)) ;; no windows in batch; keep the surface step a no-op ((symbol-function 'pearl--surface-buffer) (lambda (b) b))) (unwind-protect diff --git a/tests/test-pearl-todo-keywords.el b/tests/test-pearl-todo-keywords.el index a167157..b941b26 100644 --- a/tests/test-pearl-todo-keywords.el +++ b/tests/test-pearl-todo-keywords.el @@ -83,5 +83,43 @@ (should (string= "TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE" (pearl--derive-todo-line '())))) +;;; --gather-header-states (teams first-seen, states by position) + +(ert-deftest test-pearl-gather-header-states-orders-by-team-then-position () + "Teams come in first-seen order; within a team, states sort by position." + (cl-letf (((symbol-function 'pearl--team-states) + (lambda (tid) + (pcase tid + ("t1" '(((id . "s2") (name . "In Progress") (type . "started") (position . 2.0)) + ((id . "s1") (name . "Todo") (type . "unstarted") (position . 1.0)))) + ("t2" '(((id . "s3") (name . "Done") (type . "completed") (position . 9.0)))))))) + (let* ((issues (list (list :team '(:id "t1") :state '(:name "Todo" :type "unstarted")) + (list :team '(:id "t2") :state '(:name "Done" :type "completed")))) + (line (pearl--derive-todo-line (pearl--gather-header-states issues)))) + (should (string= "TODO IN-PROGRESS | DONE" line))))) + +(ert-deftest test-pearl-gather-header-states-covers-failed-team () + "A team whose fetch returns nil is skipped, but its issues' own states still appear." + (cl-letf (((symbol-function 'pearl--team-states) (lambda (_tid) nil))) + (let* ((issues (list (list :team '(:id "t9") :state '(:name "Grooming" :type "backlog")))) + (line (pearl--derive-todo-line (pearl--gather-header-states issues)))) + (should (string-match-p "GROOMING" line))))) + +;;; --build-org-content with a derived #+TODO + +(ert-deftest test-pearl-build-org-content-derives-todo-from-states () + "With a states list, the #+TODO line is derived (not the hardcoded default)." + (let ((out (pearl--build-org-content + '() '(:type filter :name "X" :filter nil) nil + '((:name "Todo" :type "unstarted") + (:name "Dev Review" :type "started") + (:name "Done" :type "completed"))))) + (should (string-match-p "^#\\+TODO: TODO DEV-REVIEW \\| DONE$" out)))) + +(ert-deftest test-pearl-build-org-content-no-states-keeps-fallback () + "With no states (nil), the #+TODO line stays the hardcoded default." + (let ((out (pearl--build-org-content '() '(:type filter :name "X" :filter nil) nil nil))) + (should (string-match-p "^#\\+TODO: TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED \\| DONE$" out)))) + (provide 'test-pearl-todo-keywords) ;;; test-pearl-todo-keywords.el ends here |
