aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.org2
-rw-r--r--pearl.el54
-rw-r--r--tests/test-pearl-assignee-labels.el13
-rw-r--r--tests/test-pearl-format.el4
-rw-r--r--tests/test-pearl-labels.el69
5 files changed, 128 insertions, 14 deletions
diff --git a/README.org b/README.org
index 3c5f42c..a1c4fc8 100644
--- a/README.org
+++ b/README.org
@@ -318,7 +318,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. 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=.
+- *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=. The assignee renders as a leading =@=-tag (=:@eric:bug:backend:=) so you can see who owns an issue at a glance on a team or "by person" view; set =pearl-show-assignee= to nil to omit it. It is display-only -- the =:LINEAR-ASSIGNEE-ID:= drawer is the source of truth, and the tag stays outside the title hash, so it never affects sync.
- *Comments* -- edit your own in place, or =pearl-create-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 2537cf7..a3b93c0 100644
--- a/pearl.el
+++ b/pearl.el
@@ -276,6 +276,17 @@ never leaks into the title on Linear. Set to nil to omit the prefix."
:type 'boolean
:group 'pearl)
+(defcustom pearl-show-assignee t
+ "When non-nil, render the issue assignee as a leading @-tag on the heading.
+For example `** TODO [#B] SE-401: Fix the bug :@eric:bug:'. This surfaces who
+owns an issue at a glance on a team or \"by person\" view, where the assignee
+would otherwise sit folded in the `:LINEAR-ASSIGNEE-NAME:' drawer. Display-only
+and title-sync-safe: Org strips tags before the title is hashed, and the
+assignee is never read back as a label (labels are tracked in their drawer).
+Set to nil to omit the tag."
+ :type 'boolean
+ :group 'pearl)
+
(defconst pearl--title-case-minor-words
'("a" "an" "and" "as" "at" "but" "by" "for" "if" "in" "nor" "of" "on" "or"
"per" "the" "to" "vs" "via")
@@ -2484,6 +2495,17 @@ names that slugify to the same tag render it once at the first's position."
(push tag tags))))
(nreverse tags)))
+(defun pearl--assignee-tag (assignee)
+ "Return the @-prefixed Org tag for ASSIGNEE (a normalized user plist), or nil.
+Returns nil when ASSIGNEE is nil, `pearl-show-assignee' is off, or the name
+slugifies to empty. The slug reuses `pearl--label-name-to-tag'; the `@' prefix
+keeps the assignee tag in its own namespace, distinct from label tags, so the
+label-edit path can preserve it (see `pearl--set-heading-label-tags').
+Title-sync-safe: Org strips tags before the heading title is hashed."
+ (when (and pearl-show-assignee assignee)
+ (let ((slug (pearl--label-name-to-tag (plist-get assignee :name))))
+ (unless (string-empty-p slug) (concat "@" slug)))))
+
(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
@@ -2755,7 +2777,9 @@ 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)))
+ (label-tags (pearl--label-tags (plist-get issue :labels)))
+ (assignee-tag (pearl--assignee-tag assignee))
+ (tags (if assignee-tag (cons assignee-tag label-tags) label-tags))
(tag-string (if tags (concat " :" (string-join tags ":") ":") ""))
(stars (make-string (or level 2) ?*))
(body-org (pearl--md-to-org description)))
@@ -3708,17 +3732,33 @@ inside an issue subtree."
(user-error "No team member matching %s" assignee-name))
(org-entry-put nil "LINEAR-ASSIGNEE-NAME" assignee-name)
(org-entry-put nil "LINEAR-ASSIGNEE-ID" assignee-id)
+ (pearl--set-heading-assignee-tag assignee-name)
(message "Set assignee to %s (save to push)" assignee-name))))
+(defun pearl--set-heading-assignee-tag (assignee-name)
+ "Refresh the @-assignee tag on the issue heading at point from ASSIGNEE-NAME,
+preserving the label tags so a reassign doesn't leave a stale tag until the next
+fetch. A nil/empty name (or `pearl-show-assignee' off) clears the @-tag. Point
+must be on or inside the issue heading."
+ (save-excursion
+ (org-back-to-heading t)
+ (let* ((label-tags (seq-remove (lambda (tg) (string-prefix-p "@" tg))
+ (org-get-tags nil t)))
+ (atag (pearl--assignee-tag (list :name assignee-name))))
+ (org-set-tags (if atag (cons atag label-tags) label-tags)))))
+
(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."
+ "Replace the Org label tags on the issue heading at point with TAGS, a list of
+strings. Only the label tags change: the TODO keyword, priority cookie, title,
+identifier prefix, body, drawer, and the @-prefixed assignee tag (a separate
+namespace, see `pearl--assignee-tag') are preserved. Pearl owns the label tag
+set, so any other hand-added tags are dropped. An empty TAGS list clears the
+label tags. Point must be on or inside the issue heading."
(save-excursion
(org-back-to-heading t)
- (org-set-tags tags)))
+ (let ((assignee-tags (seq-filter (lambda (tg) (string-prefix-p "@" tg))
+ (org-get-tags nil t))))
+ (org-set-tags (append assignee-tags tags)))))
;;;###autoload
(defun pearl-edit-labels (label-names)
diff --git a/tests/test-pearl-assignee-labels.el b/tests/test-pearl-assignee-labels.el
index e7be619..09d0fbd 100644
--- a/tests/test-pearl-assignee-labels.el
+++ b/tests/test-pearl-assignee-labels.el
@@ -55,6 +55,19 @@
(should (string= "Craig" (org-entry-get nil "LINEAR-ASSIGNEE-NAME")))
(should (string= "u9" (org-entry-get nil "LINEAR-ASSIGNEE-ID")))))))
+(ert-deftest test-pearl-edit-assignee-updates-heading-tag-preserving-labels ()
+ "edit-assignee refreshes the @-assignee tag on the heading (so it isn't stale
+until the next fetch) while leaving the label tags in place."
+ (test-pearl--in-org
+ "*** TODO Title :@someone:bug:\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-ASSIGNEE-ID: old\n:LINEAR-ASSIGNEE-NAME: Someone\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--resolve-team-id)
+ (lambda (_kind _name _team &optional _force) "u9")))
+ (pearl-edit-assignee "Eric")
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\*\\* TODO Title[ \t]+:@eric:bug:$" nil t))
+ (should-not (save-excursion (goto-char (point-min))
+ (re-search-forward ":@someone:" nil t))))))
+
(ert-deftest test-pearl-edit-assignee-unresolvable-errors ()
"An unresolvable assignee name signals a user error and writes nothing."
(test-pearl--in-org
diff --git a/tests/test-pearl-format.el b/tests/test-pearl-format.el
index ee15a2a..da9f975 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 :bug:backend:$" out))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing :@craig: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))
@@ -173,7 +173,7 @@ blanket :PROPERTIES: check would catch that instead of an actual id leak."
(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 :bug:backend:$" out))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] ENG-42: Fix the Thing :@craig: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
index 0195b27..3872f31 100644
--- a/tests/test-pearl-labels.el
+++ b/tests/test-pearl-labels.el
@@ -75,13 +75,34 @@
"No labels yields no tags."
(should (null (pearl--label-tags nil))))
+;;; pearl--assignee-tag
+
+(ert-deftest test-pearl-assignee-tag-prefixes-and-slugs ()
+ "An assignee renders as an @-prefixed, slugified Org tag."
+ (should (string= "@eric" (pearl--assignee-tag '(:name "Eric"))))
+ (should (string= "@eric_smith" (pearl--assignee-tag '(:name "Eric Smith")))))
+
+(ert-deftest test-pearl-assignee-tag-nil-when-unassigned ()
+ "No assignee yields no tag."
+ (should-not (pearl--assignee-tag nil)))
+
+(ert-deftest test-pearl-assignee-tag-nil-when-disabled ()
+ "With `pearl-show-assignee' off, no tag is produced even for a real assignee."
+ (let ((pearl-show-assignee nil))
+ (should-not (pearl--assignee-tag '(:name "Eric")))))
+
+(ert-deftest test-pearl-assignee-tag-empty-slug-is-nil ()
+ "A name that slugifies to empty (all punctuation) yields no tag."
+ (should-not (pearl--assignee-tag '(:name "///"))))
+
;;; rendering on the issue heading
-(defun test-pearl-labels--entry (labels)
- "Render a minimal issue with LABELS via `pearl--format-issue-as-org-entry'."
+(defun test-pearl-labels--entry (labels &optional assignee)
+ "Render a minimal issue with LABELS (and optional ASSIGNEE) 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)))
+ :priority 2 :state '(:name "Todo") :labels labels :assignee assignee)))
(ert-deftest test-pearl-format-issue-renders-label-tags ()
"Labels render as Org tags on the issue heading; the drawer keeps the names."
@@ -115,6 +136,32 @@
(re-search-forward "^\\*\\* ")
(should (string= "Fix the thing" (pearl--issue-title-at-point)))))
+(ert-deftest test-pearl-format-issue-renders-assignee-tag-before-labels ()
+ "The assignee renders as the leading @-tag, ahead of the label tags."
+ (let ((out (test-pearl-labels--entry '((:name "Bug")) '(:name "Eric"))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing :@eric:bug:$" out))))
+
+(ert-deftest test-pearl-format-issue-assignee-tag-only ()
+ "An assigned issue with no labels gets just the @-tag."
+ (let ((out (test-pearl-labels--entry nil '(:name "Eric"))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing :@eric:$" out))))
+
+(ert-deftest test-pearl-format-issue-assignee-tag-disabled ()
+ "With `pearl-show-assignee' off, the heading carries no @-tag."
+ (let* ((pearl-show-assignee nil)
+ (out (test-pearl-labels--entry '((:name "Bug")) '(:name "Eric"))))
+ (should (string-match-p "^\\*\\* TODO \\[#B\\] ENG-1: Fix the thing :bug:$" out))
+ (should-not (string-match-p "@eric" out))))
+
+(ert-deftest test-pearl-format-issue-assignee-tag-does-not-corrupt-title-sync ()
+ "The assignee tag stays outside the hashed title span, like label tags."
+ (with-temp-buffer
+ (insert (test-pearl-labels--entry '((:name "Bug")) '(:name "Eric")))
+ (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 ()
@@ -130,8 +177,22 @@
;; drawer and body untouched
(should (string= "a" (org-entry-get nil "LINEAR-ID")))))
+(ert-deftest test-pearl-set-heading-label-tags-preserves-assignee-tag ()
+ "Rewriting the label tags keeps the @-assignee tag (pearl owns label tags, but
+the assignee tag is a separate namespace) and drops other stale tags."
+ (with-temp-buffer
+ (insert "** TODO [#B] ENG-1: Fix the thing :@eric: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]+:@eric:bug:backend:$" nil t))
+ (should-not (save-excursion (goto-char (point-min)) (re-search-forward ":old:" nil t)))))
+
(ert-deftest test-pearl-set-heading-label-tags-empty-clears ()
- "An empty tag set clears the heading's tags."
+ "An empty tag set clears the heading's label tags (the @-assignee tag, if any,
+is kept -- it isn't a label)."
(with-temp-buffer
(insert "** TODO [#B] ENG-1: Fix :bug:\n")
(org-mode)