aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-03 19:07:48 -0500
committerCraig Jennings <c@cjennings.net>2026-06-03 19:07:48 -0500
commitc23592e3c175b4c31b775b57c3985bfcbef90d9c (patch)
tree35b1c7a90d1f313a8f97657eabc9454e4d82e1f7
parent8e0f0eedc91558e5d45edeb5a731103ba83e4d8b (diff)
downloadpearl-c23592e3c175b4c31b775b57c3985bfcbef90d9c.tar.gz
pearl-c23592e3c175b4c31b775b57c3985bfcbef90d9c.zip
feat(compose): @-mention Linear users from a picker
Mentioning a teammate meant recalling their exact Linear handle, dotted last names and all. Now a picker inserts it for you. In a comment or description compose buffer, typing @ at the start of a word pops a completing-read over the issue team's members, showing "Full Name (@handle)" so you can find someone by name, and inserts @displayName, the bare handle Linear resolves to a mention (no id or link wrapper). C-; L @ (pearl-mention-user) runs the same picker explicitly and also works inline in an issue buffer, resolving the team from the heading at point. An @ mid-word (an email) stays literal, and cancelling the picker leaves a literal @, so neither case is hijacked. The compose buffer now carries the issue's team id so the picker knows whom to offer. The @ trigger is bound only when a team is in context. @displayName survives the md/org round-trip unchanged, so it reaches Linear intact. Not yet confirmed: that writing a body with @displayName through the API fires Linear's mention notification (the web app does, and stored bodies carry the bare handle, so it almost certainly round-trips). Worth one self-mention write-test before relying on it.
-rw-r--r--README.org2
-rw-r--r--pearl.el86
-rw-r--r--tests/test-pearl-compose.el4
-rw-r--r--tests/test-pearl-mention.el116
4 files changed, 201 insertions, 7 deletions
diff --git a/README.org b/README.org
index a1c4fc8..86ac7df 100644
--- a/README.org
+++ b/README.org
@@ -168,6 +168,7 @@ The hot-path commands sit one key under the prefix; the rest are grouped into su
| =r= | refresh the issue at point |
| =s= / =S= | save the issue at point / save every issue in the file |
| =d= | edit the description |
+| =@= | mention a Linear user (=@displayName=) at point |
| =m= | open the full transient menu |
| =f= ... | fetch (issue sources): =s= pick source, =o= open issues, =p= by project, =f= filter |
| =v= ... | views: =l= run local, =L= run Linear, =c= create, =e= edit, =k= delete, =u= publish, =U= publish current, =d= save Linear view locally, =D= set default |
@@ -320,6 +321,7 @@ How each field is edited:
- *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=. 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.
+- *Mentions* -- in a comment or description compose buffer, type =@= at the start of a word to pop a picker over the team's members and insert =@displayName= (Linear's mention handle), so you never have to recall a teammate's exact spelling. =C-; L @= (=pearl-mention-user=) runs the same picker explicitly, and works inline in an issue buffer too. An =@= mid-word (an email, say) stays literal, and cancelling the picker leaves a literal =@=.
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 caa60e9..e897af4 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3060,6 +3060,10 @@ cancelled); `use-local' -> (pushed nil) or (failed push-failed); `use-remote'
(defvar-local pearl--compose-body-start nil
"Marker at the start of the editable body, just past the read-only header.")
+(defvar-local pearl--compose-team-id nil
+ "Linear team id of the issue this compose buffer edits, for the @-mention
+picker (`pearl-mention-user'). Nil when the composer has no team context.")
+
(defconst pearl--compose-comment-instructions
"# Write a comment below, then C-c C-c to send or C-c C-k to cancel.
# This is Org markup; it is converted to Markdown for Linear.
@@ -3093,13 +3097,15 @@ The text below the read-only header, from `pearl--compose-body-start' on."
(kill-buffer (current-buffer))
(message "Compose canceled"))
-(defun pearl--compose-in-buffer (label instructions initial on-finish)
+(defun pearl--compose-in-buffer (label instructions initial on-finish &optional team-id)
"Pop an Org compose buffer for LABEL with a read-only INSTRUCTIONS header.
INITIAL is the editable body (Org markup, may be empty). \\<global-map>C-c C-c
\(`pearl--compose-submit') hands the body to ON-FINISH and kills the buffer;
C-c C-k (`pearl--compose-abort') cancels. ON-FINISH receives the Org body and
-is responsible for any markdown conversion. The shared multi-line composer,
-sibling of `pearl--resolve-conflict-in-smerge'."
+is responsible for any markdown conversion. TEAM-ID, when non-nil, scopes the
+@-mention picker: `@' at a token boundary pops it (see
+`pearl--compose-at-self-insert'), and C-; L @ runs it explicitly. The shared
+multi-line composer, sibling of `pearl--resolve-conflict-in-smerge'."
(let ((buf (get-buffer-create (format "*pearl-compose: %s*" label))))
(with-current-buffer buf
(let ((inhibit-read-only t))
@@ -3116,12 +3122,77 @@ sibling of `pearl--resolve-conflict-in-smerge'."
(setq pearl--compose-body-start (copy-marker end nil)))
(insert (or initial "")))
(setq-local pearl--compose-on-finish on-finish)
+ (setq-local pearl--compose-team-id team-id)
(local-set-key (kbd "C-c C-c") #'pearl--compose-submit)
(local-set-key (kbd "C-c C-k") #'pearl--compose-abort)
+ (when team-id (local-set-key (kbd "@") #'pearl--compose-at-self-insert))
(goto-char (point-max)))
(pop-to-buffer buf (pearl--compose-display-action))
buf))
+;;; @-mention picker
+
+(defun pearl--mention-candidates (members)
+ "Build a completing-read alist for MEMBERS (team member node alists).
+Each entry maps a display string to the `@displayName' mention token Linear
+resolves on its side. A member with no displayName has no handle and is
+dropped. Display is \"Name (@handle)\" when the name adds information (so you
+can find a teammate without recalling the handle), else just \"@handle\"."
+ (delq nil
+ (mapcar (lambda (m)
+ (let ((handle (cdr (assoc 'displayName m)))
+ (name (cdr (assoc 'name m))))
+ (when (and handle (not (string-empty-p handle)))
+ (cons (if (and name (not (string= name handle)))
+ (format "%s (@%s)" name handle)
+ (format "@%s" handle))
+ (concat "@" handle)))))
+ members)))
+
+(defun pearl--read-mention (team-id)
+ "Prompt for a member of TEAM-ID and return the `@displayName' token.
+Errors when the team has no mentionable members. A quit (C-g) propagates so
+callers can decide what an aborted pick means."
+ (let ((cands (pearl--mention-candidates (pearl--team-collection 'members team-id))))
+ (unless cands
+ (user-error "No members to mention for this team"))
+ (cdr (assoc (completing-read "Mention: " cands nil t) cands))))
+
+(defun pearl--mention-team-id ()
+ "Resolve the team id for a mention from context: the compose buffer's stored
+team, else the issue heading at point. Returns nil when neither is available."
+ (or pearl--compose-team-id
+ (save-excursion
+ (when (ignore-errors (org-back-to-heading t) t)
+ (org-entry-get nil "LINEAR-TEAM-ID")))))
+
+;;;###autoload
+(defun pearl-mention-user ()
+ "Insert an @-mention of a Linear user at point.
+Completes over the team's members (resolved from the compose buffer or the issue
+at point) and inserts `@displayName' -- the bare handle Linear resolves to a
+mention, no id or link wrapper. Signals a `user-error' when no team context is
+available."
+ (interactive)
+ (let ((team-id (pearl--mention-team-id)))
+ (unless team-id
+ (user-error "No Linear team in context -- mention from a compose buffer or an issue"))
+ (let ((mention (pearl--read-mention team-id)))
+ (when mention (insert mention)))))
+
+(defun pearl--compose-at-self-insert ()
+ "Bound to `@' in a compose buffer that has a team.
+At a token boundary (line start or after whitespace) pop the mention picker and
+insert the chosen `@displayName'; a non-boundary position, or cancelling the
+picker, inserts a literal `@' -- so an email or a literal at-sign still works."
+ (interactive)
+ (if (and pearl--compose-team-id
+ (or (bolp) (memq (char-before) '(?\s ?\t ?\n))))
+ (condition-case nil
+ (insert (or (pearl--read-mention pearl--compose-team-id) "@"))
+ (quit (insert "@")))
+ (insert "@")))
+
(defvar-local pearl--conflict-on-finish nil
"Callback invoked with the reconciled text when a conflict buffer commits.")
@@ -3548,6 +3619,7 @@ the new comment is the viewer's own, so it renders editable."
(save-excursion
(pearl--goto-issue-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (team-id (org-entry-get nil "LINEAR-TEAM-ID"))
(marker (point-marker)))
(if body
(pearl--create-and-append-comment issue-id marker body)
@@ -3556,7 +3628,8 @@ the new comment is the viewer's own, so it renders editable."
pearl--compose-comment-instructions ""
(lambda (org)
(pearl--create-and-append-comment
- issue-id marker (pearl--org-to-md org))))))))
+ issue-id marker (pearl--org-to-md org)))
+ team-id)))))
;;;###autoload
(defun pearl-edit-description ()
@@ -3570,6 +3643,7 @@ a dedicated buffer. Works from anywhere inside an issue subtree."
(save-excursion
(pearl--goto-issue-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (team-id (org-entry-get nil "LINEAR-TEAM-ID"))
(marker (point-marker)))
(pearl--compose-in-buffer
(format "description for %s" issue-id)
@@ -3578,7 +3652,8 @@ a dedicated buffer. Works from anywhere inside an issue subtree."
(lambda (org)
(org-with-point-at marker
(pearl--set-entry-body-at-point org)
- (pearl-sync-current-issue)))))))
+ (pearl-sync-current-issue)))
+ team-id))))
;;;###autoload
(defun pearl-open-current-issue ()
@@ -7361,6 +7436,7 @@ diagnostics.")
(define-key map "s" (cons "save issue" #'pearl-save-issue))
(define-key map "S" (cons "save all" #'pearl-save-all))
(define-key map "d" (cons "edit description" #'pearl-edit-description))
+ (define-key map "@" (cons "mention user" #'pearl-mention-user))
(define-key map "m" (cons "menu" #'pearl-menu))
;; Category sub-maps -- two keys; each prefix names an action verb or noun.
(define-key map "f" (cons "fetch..." pearl-fetch-map))
diff --git a/tests/test-pearl-compose.el b/tests/test-pearl-compose.el
index 4f4d611..db56ebc 100644
--- a/tests/test-pearl-compose.el
+++ b/tests/test-pearl-compose.el
@@ -107,7 +107,7 @@ Binds `captured' to the value the on-finish receives (or `:none'). Stubs
(test-pearl--in-issue
(let (sent)
(cl-letf (((symbol-function 'pearl--compose-in-buffer)
- (lambda (_label _instr initial on-finish)
+ (lambda (_label _instr initial on-finish &optional _team-id)
;; the composer would start empty for a new comment
(should (string= "" initial))
(funcall on-finish "see *bold* and `code`")))
@@ -136,7 +136,7 @@ Binds `captured' to the value the on-finish receives (or `:none'). Stubs
(test-pearl--in-issue
(let (seeded synced)
(cl-letf (((symbol-function 'pearl--compose-in-buffer)
- (lambda (_label _instr initial on-finish)
+ (lambda (_label _instr initial on-finish &optional _team-id)
(setq seeded initial)
(funcall on-finish "Edited description")))
((symbol-function 'pearl-sync-current-issue)
diff --git a/tests/test-pearl-mention.el b/tests/test-pearl-mention.el
new file mode 100644
index 0000000..dd0ef0d
--- /dev/null
+++ b/tests/test-pearl-mention.el
@@ -0,0 +1,116 @@
+;;; test-pearl-mention.el --- Tests for @-mentioning Linear users -*- 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 user @-mention picker: the candidate builder, the insert
+;; command (`pearl-mention-user'), the compose-buffer self-inserting `@'
+;; trigger, and the keymap binding.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defun test-pearl-mention--members ()
+ "Member node alists as `pearl--team-collection' returns them."
+ '(((id . "u1") (name . "Eric Bell") (displayName . "eric") (email . "e@x"))
+ ((id . "u2") (name . "vrezh.mikayelyan@deepsat.com")
+ (displayName . "vrezh.mikayelyan") (email . "v@x"))
+ ((id . "u3") (name . "Bot") (displayName . nil) (email . "b@x"))))
+
+;;; --mention-candidates
+
+(ert-deftest test-pearl-mention-candidates-maps-to-handle ()
+ "Each member maps a display string to its `@displayName' token; the display
+carries the full name so you can find a teammate without recalling the handle."
+ (let ((cands (pearl--mention-candidates (test-pearl-mention--members))))
+ (should (equal "@eric" (cdr (assoc "Eric Bell (@eric)" cands))))
+ (should (equal "@vrezh.mikayelyan"
+ (cdr (assoc "vrezh.mikayelyan@deepsat.com (@vrezh.mikayelyan)" cands))))))
+
+(ert-deftest test-pearl-mention-candidates-drops-handleless ()
+ "A member with no displayName has no mention handle and is dropped."
+ (let ((cands (pearl--mention-candidates (test-pearl-mention--members))))
+ (should-not (cl-find "@" (mapcar #'cdr cands)
+ :test (lambda (_ v) (string-match-p "Bot" (or v "")))))
+ (should (= 2 (length cands)))))
+
+;;; pearl-mention-user (insert at point)
+
+(ert-deftest test-pearl-mention-user-inserts-handle-at-point ()
+ "With a team in context, the command inserts the chosen `@displayName'."
+ (cl-letf (((symbol-function 'pearl--team-collection)
+ (lambda (_kind _team &optional _force) (test-pearl-mention--members)))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) "Eric Bell (@eric)")))
+ (with-temp-buffer
+ (insert "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: t1\n:END:\nPing ")
+ (org-mode)
+ (goto-char (point-max))
+ (pearl-mention-user)
+ (should (string-suffix-p "Ping @eric" (buffer-substring (line-beginning-position) (point)))))))
+
+(ert-deftest test-pearl-mention-user-no-team-errors ()
+ "Outside any team context (no compose team, no issue heading) it errors."
+ (with-temp-buffer
+ (insert "plain text, no Linear heading")
+ (org-mode)
+ (goto-char (point-max))
+ (should-error (pearl-mention-user) :type 'user-error)))
+
+;;; keymap
+
+(ert-deftest test-pearl-mention-bound-under-prefix ()
+ "C-; L @ resolves to the mention command."
+ (should (eq 'pearl-mention-user (lookup-key pearl-prefix-map "@"))))
+
+;;; compose-buffer self-inserting @
+
+(ert-deftest test-pearl-compose-at-self-insert-picks-at-boundary ()
+ "At a token boundary the `@' trigger pops the picker and inserts the handle."
+ (cl-letf (((symbol-function 'pearl--read-mention) (lambda (_team) "@eric")))
+ (with-temp-buffer
+ (setq-local pearl--compose-team-id "t1")
+ (insert "Ping ") ; point preceded by a space = boundary
+ (pearl--compose-at-self-insert)
+ (should (string= "Ping @eric" (buffer-string))))))
+
+(ert-deftest test-pearl-compose-at-self-insert-literal-mid-token ()
+ "Mid-token (e.g. inside an email) `@' inserts a literal at-sign, no picker."
+ (cl-letf (((symbol-function 'pearl--read-mention)
+ (lambda (_team) (error "picker should not run mid-token"))))
+ (with-temp-buffer
+ (setq-local pearl--compose-team-id "t1")
+ (insert "foo") ; point after a word char = not a boundary
+ (pearl--compose-at-self-insert)
+ (should (string= "foo@" (buffer-string))))))
+
+(ert-deftest test-pearl-compose-at-self-insert-cancel-leaves-literal ()
+ "Cancelling the picker (quit) leaves a literal `@' rather than aborting input."
+ (cl-letf (((symbol-function 'pearl--read-mention)
+ (lambda (_team) (signal 'quit nil))))
+ (with-temp-buffer
+ (setq-local pearl--compose-team-id "t1")
+ (insert "Ping ")
+ (pearl--compose-at-self-insert)
+ (should (string= "Ping @" (buffer-string))))))
+
+(provide 'test-pearl-mention)
+;;; test-pearl-mention.el ends here