aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-27 15:56:16 -0500
committerCraig Jennings <c@cjennings.net>2026-05-27 15:56:16 -0500
commit78bd607f8dd1f4c29e4d7dda9744b06bf7352105 (patch)
tree7ed2e625334484b4f72044748f33849cc0a643a2
parent805e288178ce2b96dd4a0277b49904131853e8fe (diff)
downloadpearl-78bd607f8dd1f4c29e4d7dda9744b06bf7352105.tar.gz
pearl-78bd607f8dd1f4c29e4d7dda9744b06bf7352105.zip
feat(labels): render Linear labels as Org tags on issue headings
An issue's Linear labels now appear as Org tags on its heading (** TODO [#B] Title :bug:backend:), so the org-native gestures work: filter by tag, build a tag agenda, sparse-tree on :bug:. Before this, labels lived only in the :LINEAR-LABELS: drawer, which Org's tag machinery can't see. pearl--label-name-to-tag slugifies a label name to a tag: downcase, non-[[:alnum:]_] runs become a single underscore, Unicode letters preserved. pearl--label-tags builds the deduplicated set, first occurrence winning on a collision. The renderer appends them, and pearl-edit-labels rewrites them through pearl--set-heading-label-tags after updating the drawer. This is render-only. The :LINEAR-LABELS: drawer stays the source of truth, hand-edited heading tags are ignored by save and the dirty scan and get rewritten from Linear on the next change or fetch, and the way to change labels is still pearl-edit-labels. Bidirectional tag editing (heading tags back to Linear) is out of scope.
-rw-r--r--README.org2
-rw-r--r--pearl.el44
-rw-r--r--tests/test-pearl-format.el4
-rw-r--r--tests/test-pearl-labels.el193
4 files changed, 238 insertions, 5 deletions
diff --git a/README.org b/README.org
index cb3802e..ad12b7d 100644
--- a/README.org
+++ b/README.org
@@ -229,7 +229,7 @@ How each field is edited:
- *Title and description* -- type in the heading and the body. =pearl-edit-description= pops a focused compose buffer if you'd rather not edit inline.
- *Priority* -- the Org priority cookie (=C-c ,=, or =S-up= / =S-down=). =[#A]/[#B]/[#C]/[#D]= are Urgent/High/Medium/Low; no cookie is None.
- *State* -- =pearl-edit-state=, completing over the team's workflow states.
-- *Assignee and labels* -- =pearl-edit-assignee= / =pearl-edit-labels=, completing over the team's members and labels.
+- *Assignee and labels* -- =pearl-edit-assignee= / =pearl-edit-labels=, completing over the team's members and labels. Labels also render as Org tags on the heading (=:bug:backend:=), so you can filter, sparse-tree, and build agendas on them. The =:LINEAR-LABELS:= drawer stays the source of truth; hand-edited heading tags are ignored and rewritten from Linear on the next change or fetch, so change labels with =pearl-edit-labels=.
- *Comments* -- edit your own in place, or =pearl-add-comment= for a new one.
Picking a constrained field writes the value into the buffer and marks it changed; it doesn't push until you save. The display name or label text is there to read -- Pearl reconciles by the underlying id, and a refresh rewrites the display from the remote, so hand-editing the visible name has no effect.
diff --git a/pearl.el b/pearl.el
index bc9319d..18c65c2 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1535,6 +1535,32 @@ state-name-to-keyword mapping."
"-+" "-+")))
(if (string-empty-p slug) "TODO" slug)))
+(defun pearl--label-name-to-tag (name)
+ "Slugify a Linear label NAME to an Org tag.
+Downcased, each run of characters outside [[:alnum:]_] replaced by a single
+underscore (alnum is Unicode-aware, so accented and non-Latin letters survive),
+with leading and trailing underscores trimmed. Org tags allow no hyphens or
+spaces, unlike the TODO keywords `pearl--state-name-to-keyword' builds. A name
+that slugifies to empty (all punctuation) returns the empty string; the caller
+drops it from the heading and keeps it in the `:LINEAR-LABELS:' drawer."
+ (string-trim
+ (replace-regexp-in-string "[^[:alnum:]_]+" "_" (downcase (or name "")))
+ "_+" "_+"))
+
+(defun pearl--label-tags (labels)
+ "Return the ordered, de-duplicated Org tags for a list of LABEL plists.
+Each label's `:name' is slugified via `pearl--label-name-to-tag'; empty slugs
+are dropped and duplicates removed, first occurrence winning, so two label
+names that slugify to the same tag render it once at the first's position."
+ (let ((seen (make-hash-table :test 'equal))
+ (tags '()))
+ (dolist (label labels)
+ (let ((tag (pearl--label-name-to-tag (plist-get label :name))))
+ (unless (or (string-empty-p tag) (gethash tag seen))
+ (puthash tag t seen)
+ (push tag tags))))
+ (nreverse tags)))
+
(defun pearl--derive-todo-line (states)
"Build the `#+TODO:' keyword string from an ordered STATES list.
Each state is a plist with at least :name and :type. States whose :type is
@@ -1804,11 +1830,13 @@ identifier prefix -- so an unedited heading is a no-op on title sync)."
(plist-get issue :labels) ", "))
(label-ids (mapconcat (lambda (l) (or (plist-get l :id) ""))
(plist-get issue :labels) " "))
+ (tags (pearl--label-tags (plist-get issue :labels)))
+ (tag-string (if tags (concat " :" (string-join tags ":") ":") ""))
(body-org (pearl--md-to-org description)))
(concat
- (format "** %s %s%s\n" todo
+ (format "** %s %s%s%s\n" todo
(if (string-empty-p priority) "" (concat priority " "))
- heading-title)
+ heading-title tag-string)
":PROPERTIES:\n"
(format ":LINEAR-ID: %s\n" (or (plist-get issue :id) ""))
(format ":LINEAR-IDENTIFIER: %s\n" (or (plist-get issue :identifier) ""))
@@ -2747,6 +2775,16 @@ inside an issue subtree."
(org-entry-put nil "LINEAR-ASSIGNEE-ID" assignee-id)
(message "Set assignee to %s (save to push)" assignee-name))))
+(defun pearl--set-heading-label-tags (tags)
+ "Replace the Org tags on the issue heading at point with TAGS, a list of strings.
+Only the heading's tag set changes: the TODO keyword, priority cookie, title,
+identifier prefix, body, and drawer are preserved. Pearl owns the entire
+issue-heading tag set, so any hand-added tags are dropped. An empty TAGS list
+clears the heading's tags. Point must be on or inside the issue heading."
+ (save-excursion
+ (org-back-to-heading t)
+ (org-set-tags tags)))
+
;;;###autoload
(defun pearl-edit-labels (label-names)
"Set the labels of the issue at point to LABEL-NAMES in the buffer.
@@ -2774,6 +2812,8 @@ inside an issue subtree."
(org-entry-put nil "LINEAR-LABELS"
(format "[%s]" (mapconcat #'identity label-names ", ")))
(org-entry-put nil "LINEAR-LABEL-IDS" (string-join label-ids " "))
+ (pearl--set-heading-label-tags
+ (pearl--label-tags (mapcar (lambda (n) (list :name n)) label-names)))
(message "Set labels to %s (save to push)"
(if label-names (mapconcat #'identity label-names ", ") "none")))))
diff --git a/tests/test-pearl-format.el b/tests/test-pearl-format.el
index 53af2e7..491348a 100644
--- a/tests/test-pearl-format.el
+++ b/tests/test-pearl-format.el
@@ -51,7 +51,7 @@ so these tests no longer need a state mapping bound."
"A full issue renders the heading and the namespaced LINEAR-* drawer."
(test-pearl--with-default-mapping
(let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-full))))
- (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing$" out))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing :bug:backend:$" out))
(should (string-match-p "^:LINEAR-ID: +uuid-1$" out))
(should (string-match-p "^:LINEAR-IDENTIFIER: +ENG-42$" out))
(should (string-match-p "^:LINEAR-STATE-NAME: +In Progress$" out))
@@ -168,7 +168,7 @@ sort together (org-sort on the parent) instead of being orphan headings."
(test-pearl--with-default-mapping
(let ((out (pearl--build-org-content
(list (test-pearl--norm-full) (test-pearl--norm-bare)))))
- (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing$" out))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing :bug:backend:$" out))
(should (string-match-p "^\\*\\* TODO ENG-7: Bare Issue$" out)))))
;;; --restore-page-visibility
diff --git a/tests/test-pearl-labels.el b/tests/test-pearl-labels.el
new file mode 100644
index 0000000..0195b27
--- /dev/null
+++ b/tests/test-pearl-labels.el
@@ -0,0 +1,193 @@
+;;; test-pearl-labels.el --- Tests for label-to-tag slugify and tag set -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the pure helpers behind "Linear labels as Org tags"
+;; (docs/labels-as-org-tags-spec.org): `pearl--label-name-to-tag' slugifies one
+;; label name to an Org tag, and `pearl--label-tags' turns a list of label
+;; plists into the ordered, de-duplicated tag set for a heading.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+;;; pearl--label-name-to-tag
+
+(ert-deftest test-pearl-label-name-to-tag-downcases-and-slugs ()
+ "A label name downcases; spaces and other non-tag chars become single underscores."
+ (should (string= "bug" (pearl--label-name-to-tag "Bug")))
+ (should (string= "needs_review" (pearl--label-name-to-tag "Needs Review")))
+ (should (string= "ui_ux" (pearl--label-name-to-tag "UI/UX")))
+ (should (string= "backend_api" (pearl--label-name-to-tag "backend/api")))
+ (should (string= "p1" (pearl--label-name-to-tag "P1"))))
+
+(ert-deftest test-pearl-label-name-to-tag-preserves-unicode-alnum ()
+ "Unicode letters survive (alnum is Unicode-aware), downcased."
+ (should (string= "naïve_café" (pearl--label-name-to-tag "naïve café"))))
+
+(ert-deftest test-pearl-label-name-to-tag-collapses-runs-and-trims ()
+ "Runs of non-tag characters collapse to one underscore; leading/trailing are trimmed."
+ (should (string= "a_b" (pearl--label-name-to-tag " a // b ")))
+ (should (string= "x" (pearl--label-name-to-tag "_x_"))))
+
+(ert-deftest test-pearl-label-name-to-tag-punctuation-only-is-empty ()
+ "A name with no tag-legal characters slugifies to the empty string."
+ (should (string= "" (pearl--label-name-to-tag "///")))
+ (should (string= "" (pearl--label-name-to-tag "")))
+ (should (string= "" (pearl--label-name-to-tag nil))))
+
+;;; pearl--label-tags
+
+(ert-deftest test-pearl-label-tags-maps-in-order ()
+ "Each label's :name slugifies; order is preserved."
+ (should (equal '("bug" "backend")
+ (pearl--label-tags '((:id "1" :name "Bug") (:id "2" :name "Backend"))))))
+
+(ert-deftest test-pearl-label-tags-dedupes-collisions-first-wins ()
+ "Two names that slugify the same render one tag, at the first's position."
+ (should (equal '("a_b")
+ (pearl--label-tags '((:name "A/B") (:name "A B"))))))
+
+(ert-deftest test-pearl-label-tags-drops-empty-slugs ()
+ "A punctuation-only label contributes no tag."
+ (should (equal '("bug")
+ (pearl--label-tags '((:name "Bug") (:name "///"))))))
+
+(ert-deftest test-pearl-label-tags-empty-input ()
+ "No labels yields no tags."
+ (should (null (pearl--label-tags nil))))
+
+;;; rendering on the issue heading
+
+(defun test-pearl-labels--entry (labels)
+ "Render a minimal issue with LABELS via `pearl--format-issue-as-org-entry'."
+ (pearl--format-issue-as-org-entry
+ (list :id "u" :identifier "ENG-1" :title "Fix the thing"
+ :priority 2 :state '(:name "Todo") :labels labels)))
+
+(ert-deftest test-pearl-format-issue-renders-label-tags ()
+ "Labels render as Org tags on the issue heading; the drawer keeps the names."
+ (let ((out (test-pearl-labels--entry '((:id "1" :name "Bug") (:id "2" :name "Backend")))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing :bug:backend:$" out))
+ (should (string-match-p "^:LINEAR-LABELS: +\\[Bug, Backend\\]$" out))))
+
+(ert-deftest test-pearl-format-issue-no-labels-no-tags ()
+ "An issue with no labels gets no tag string on the heading."
+ (let ((out (test-pearl-labels--entry nil)))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing$" out))))
+
+(ert-deftest test-pearl-format-issue-label-tag-collision-renders-once ()
+ "Two labels that slugify the same render one tag; the drawer keeps both names."
+ (let ((out (test-pearl-labels--entry '((:name "A/B") (:name "A B")))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing :a_b:$" out))
+ (should (string-match-p "^:LINEAR-LABELS: +\\[A/B, A B\\]$" out))))
+
+(ert-deftest test-pearl-format-issue-empty-slug-label-not-on-heading ()
+ "A punctuation-only label is absent from the heading but present in the drawer."
+ (let ((out (test-pearl-labels--entry '((:name "Bug") (:name "///")))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing :bug:$" out))
+ (should (string-match-p "^:LINEAR-LABELS: +\\[Bug, ///\\]$" out))))
+
+(ert-deftest test-pearl-format-issue-tags-do-not-corrupt-title-sync ()
+ "With tags present, the title at point is still the bare title (no tags/keyword/prefix)."
+ (with-temp-buffer
+ (insert (test-pearl-labels--entry '((:name "Bug") (:name "Backend"))))
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (should (string= "Fix the thing" (pearl--issue-title-at-point)))))
+
+;;; pearl--set-heading-label-tags
+
+(ert-deftest test-pearl-set-heading-label-tags-replaces-preserving-the-rest ()
+ "Replacing the tag set leaves the keyword, cookie, title, and identifier intact."
+ (with-temp-buffer
+ (insert "** TODO [#B] ENG-1: Fix the thing :old:\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nbody\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (pearl--set-heading-label-tags '("bug" "backend"))
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing[ \t]+:bug:backend:$" nil t))
+ ;; drawer and body untouched
+ (should (string= "a" (org-entry-get nil "LINEAR-ID")))))
+
+(ert-deftest test-pearl-set-heading-label-tags-empty-clears ()
+ "An empty tag set clears the heading's tags."
+ (with-temp-buffer
+ (insert "** TODO [#B] ENG-1: Fix :bug:\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (pearl--set-heading-label-tags nil)
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\* TODO \\[#B\\] ENG-1: Fix[ \t]*$" nil t))
+ (should-not (save-excursion (goto-char (point-min)) (re-search-forward ":bug:" nil t)))))
+
+;;; pearl-edit-labels rewrites both the drawer and the heading tags
+
+(ert-deftest test-pearl-edit-labels-rewrites-heading-tags-and-drawer ()
+ "Picking labels updates the drawer and rewrites the heading tags, dropping stale ones."
+ (cl-letf (((symbol-function 'pearl--resolve-team-id)
+ (lambda (_type name _team) (concat "id-" name))))
+ (with-temp-buffer
+ (insert "** TODO ENG-1: Fix :stale:\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: t1\n:LINEAR-LABELS: [old]\n:LINEAR-LABEL-IDS: x\n:END:\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (pearl-edit-labels '("Bug" "Backend"))
+ (should (string= "[Bug, Backend]" (org-entry-get nil "LINEAR-LABELS")))
+ (should (string= "id-Bug id-Backend" (org-entry-get nil "LINEAR-LABEL-IDS")))
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\* TODO ENG-1: Fix[ \t]+:bug:backend:$" nil t))
+ (should-not (save-excursion (goto-char (point-min)) (re-search-forward ":stale:" nil t))))))
+
+(ert-deftest test-pearl-edit-labels-clear-removes-tags-and-empties-drawer ()
+ "Clearing the selection removes heading tags and sets the drawer to []."
+ (with-temp-buffer
+ (insert "** TODO ENG-1: Fix :bug:\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: t1\n:LINEAR-LABELS: [Bug]\n:LINEAR-LABEL-IDS: id-Bug\n:END:\n")
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (pearl-edit-labels nil)
+ (should (string= "[]" (org-entry-get nil "LINEAR-LABELS")))
+ (should (string= "" (org-entry-get nil "LINEAR-LABEL-IDS")))
+ (should-not (save-excursion (goto-char (point-min)) (re-search-forward ":bug:" nil t)))))
+
+;;; save isolation -- a tag edit is never a dirty field
+
+(ert-deftest test-pearl-label-tags-edit-is-not-a-dirty-field ()
+ "Hand-editing the heading tags does not mark the issue's labels dirty.
+The dirty scan reads the LABEL-IDS drawer, not the heading tags, so a tag-only
+change is never treated as a pushable edit."
+ (with-temp-buffer
+ (insert (test-pearl-labels--entry '((:id "lbl-1" :name "Bug"))))
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ ;; freshly rendered: LABEL-IDS == LABEL-IDS-SYNCED, so not dirty
+ (should-not (pearl--label-ids-dirty-p))
+ ;; hand-add a stray heading tag; the drawer is untouched
+ (org-set-tags '("bug" "manual"))
+ (should-not (pearl--label-ids-dirty-p))))
+
+(provide 'test-pearl-labels)
+;;; test-pearl-labels.el ends here