aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
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 /pearl.el
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.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el86
1 files changed, 81 insertions, 5 deletions
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))