From 6924c27963ddd25f91ea3a1a02d462f0badabfc4 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 25 May 2026 22:35:12 -0500 Subject: feat(render): rebuild the derived #+TODO header on merge refresh A same-source refresh updates issue subtrees in place and keeps retained dirty subtrees the merge skips, so the #+TODO line has to stay honest across it -- a refresh can surface a new state, and a kept heading's keyword must still be declared. pearl--update-derived-todo-header scans the final displayed buffer (every LINEAR-ID heading's TODO keyword + :LINEAR-STATE-TYPE: drawer), unions that with the fetched teams' states, and rewrites the line via pearl--derive-todo-line. Scanning the final buffer rather than the fetched issue list is what covers the retained subtrees. A legacy heading with no state-type drawer is classified by org-done-keywords. pearl--merge-query-result calls it after the merge, beside the source-header update. The merge and integration test setups stub pearl--team-states so the gather stays network-free. --- pearl.el | 31 +++++++++++++++++++++++++++++++ tests/test-pearl-merge.el | 14 +++++++++----- tests/test-pearl-todo-keywords.el | 29 +++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/pearl.el b/pearl.el index 19166db..c61180c 100644 --- a/pearl.el +++ b/pearl.el @@ -4063,6 +4063,36 @@ provenance advances on a refresh." (when (re-search-forward "^#\\+LINEAR-TRUNCATED: .*$" nil t) (replace-match (format "#+LINEAR-TRUNCATED: %s" (if truncated "yes" "no")) t t)))) +(defun pearl--update-derived-todo-header (states) + "Rewrite the buffer's `#+TODO:' line from the final headings plus STATES. +Scans every Linear issue heading (carrying `LINEAR-ID') for its TODO keyword +and `:LINEAR-STATE-TYPE:' drawer, unions those with STATES (the fetched teams' +states, see `pearl--gather-header-states'), and rewrites the line via +`pearl--derive-todo-line'. Scanning the final buffer -- not just the fetched +issue list -- keeps retained / skipped dirty subtrees declared. A heading with +no state-type drawer (a legacy render) is classified by `org-done-keywords'. +Creates the line after `#+STARTUP:' if it is absent." + (save-excursion + (let (scanned) + (goto-char (point-min)) + (while (re-search-forward "^\\*+ " nil t) + (when (org-entry-get nil "LINEAR-ID") + (let ((kw (org-get-todo-state)) + (type (org-entry-get nil "LINEAR-STATE-TYPE"))) + (when kw + (push (list :name kw + :type (cond ((and type (not (string-empty-p type))) type) + ((member kw org-done-keywords) "completed") + (t nil))) + scanned))))) + (let ((line (pearl--derive-todo-line (append states (nreverse scanned))))) + (goto-char (point-min)) + (if (re-search-forward "^#\\+TODO: .*$" nil t) + (replace-match (format "#+TODO: %s" line) t t) + (when (re-search-forward "^#\\+STARTUP: .*$" nil t) + (end-of-line) + (insert (format "\n#+TODO: %s" line)))))))) + (defun pearl--merge-query-result (result source) "Merge a query RESULT into the current buffer by `LINEAR-ID', tagged with SOURCE. The same-source refresh counterpart to `pearl--render-query-result': rather than @@ -4083,6 +4113,7 @@ of the replace path." (truncated (pearl--query-result-truncated-p result)) (counts (pearl--merge-issues-into-buffer issues))) (pearl--update-source-header (length issues) truncated) + (pearl--update-derived-todo-header (pearl--gather-header-states issues)) (pearl-highlight-comments) (pearl--restore-page-visibility) (pearl--surface-buffer (current-buffer)) diff --git a/tests/test-pearl-merge.el b/tests/test-pearl-merge.el index e55127b..ba73cc6 100644 --- a/tests/test-pearl-merge.el +++ b/tests/test-pearl-merge.el @@ -38,11 +38,15 @@ `(let ((pearl-state-to-todo-mapping '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE"))) (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE")))) - (with-temp-buffer - (insert ,content) - (org-mode) - (goto-char (point-min)) - ,@body))) + ;; the merge's derived-header rebuild gathers team states synchronously; + ;; stub it so the merge tests stay network-free (the header then derives + ;; from the displayed issues' own states) + (cl-letf (((symbol-function 'pearl--team-states) (lambda (_team-id) nil))) + (with-temp-buffer + (insert ,content) + (org-mode) + (goto-char (point-min)) + ,@body)))) (defun test-pearl-merge--issue (id title desc) "A normalized issue plist with ID, TITLE, and DESC for merge input." diff --git a/tests/test-pearl-todo-keywords.el b/tests/test-pearl-todo-keywords.el index b941b26..281beea 100644 --- a/tests/test-pearl-todo-keywords.el +++ b/tests/test-pearl-todo-keywords.el @@ -121,5 +121,34 @@ (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)))) +;;; --update-derived-todo-header (merge-refresh, final-buffer scan) + +(ert-deftest test-pearl-update-derived-todo-header-covers-buffer-headings () + "The header rebuild scans the final buffer so every visible heading is declared." + (let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE")))) + (with-temp-buffer + (insert "#+STARTUP: show3levels\n#+TODO: TODO | DONE\n\n" + "* View\n" + "** IN-PROGRESS Issue A\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-STATE-TYPE: started\n:END:\n" + "** DONE Issue B\n:PROPERTIES:\n:LINEAR-ID: b\n:LINEAR-STATE-TYPE: completed\n:END:\n") + (org-mode) + ;; no team states passed -- the rebuild comes purely from the buffer scan + (pearl--update-derived-todo-header '()) + (goto-char (point-min)) + (should (re-search-forward "^#\\+TODO: IN-PROGRESS \\| DONE$" nil t))))) + +(ert-deftest test-pearl-update-derived-todo-header-classifies-legacy-by-done-keyword () + "A retained heading with no STATE-TYPE drawer is classified by org-done-keywords." + (let ((org-todo-keywords '((sequence "TODO" "|" "SHIPPED")))) + (with-temp-buffer + (insert "#+STARTUP: show3levels\n#+TODO: TODO | SHIPPED\n\n" + "* View\n" + "** SHIPPED Old Issue\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n") ; no STATE-TYPE + (org-mode) + (pearl--update-derived-todo-header '()) + (goto-char (point-min)) + ;; SHIPPED is an org done-keyword, so it lands on the done side + (should (re-search-forward "^#\\+TODO: *\\| SHIPPED$" nil t))))) + (provide 'test-pearl-todo-keywords) ;;; test-pearl-todo-keywords.el ends here -- cgit v1.2.3