diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-03 19:07:48 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-03 19:07:48 -0500 |
| commit | c23592e3c175b4c31b775b57c3985bfcbef90d9c (patch) | |
| tree | 35b1c7a90d1f313a8f97657eabc9454e4d82e1f7 | |
| parent | 8e0f0eedc91558e5d45edeb5a731103ba83e4d8b (diff) | |
| download | pearl-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.org | 2 | ||||
| -rw-r--r-- | pearl.el | 86 | ||||
| -rw-r--r-- | tests/test-pearl-compose.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-mention.el | 116 |
4 files changed, 201 insertions, 7 deletions
@@ -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. @@ -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 |
