diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-23 23:40:27 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-23 23:40:27 -0400 |
| commit | 8869a083c3cc28c20efa63e8932347329b2865f4 (patch) | |
| tree | 8959775eb2cae28a3418542ab8e2fc235fe465f2 | |
| parent | 92ae9d8b56a9fabadf6ea8beb2163655c7a8ffb8 (diff) | |
| download | pearl-8869a083c3cc28c20efa63e8932347329b2865f4.tar.gz pearl-8869a083c3cc28c20efa63e8932347329b2865f4.zip | |
feat(sources): single-issue source (favorites + open-issue-by-id)
I wired the issue kind into the same pipeline every other source uses, so a favorited issue renders in the buffer as its own subtree instead of the v1 browser punt. The new pearl-open-issue-by-id command does the same for an issue you type by identifier or id. It's handy when someone hands you ENG-123 and you'd rather read it in Pearl than a browser.
Linear's issue(id:) accepts both the UUID and the human identifier, so there's no resolution step. I fetch by whatever was given and build the :type issue source from the returned node, so the stored id and identifier stay authoritative. Refresh re-fetches the one issue and merges by LINEAR-ID like the filter and view sources.
Tests cover the source constructor, the header round-trip, and the fetch/render and command paths across Normal, Boundary, and Error.
| -rw-r--r-- | README.org | 57 | ||||
| -rw-r--r-- | pearl.el | 74 | ||||
| -rw-r--r-- | tests/test-pearl-favorites.el | 12 | ||||
| -rw-r--r-- | tests/test-pearl-menu.el | 2 | ||||
| -rw-r--r-- | tests/test-pearl-single-issue.el | 117 |
5 files changed, 229 insertions, 33 deletions
@@ -177,7 +177,8 @@ The hot-path commands sit one key under the prefix; the rest are grouped into su |-----------+----------------------------------------------------------------------------------------------------------| | =m= | open the full transient menu | |-----------+----------------------------------------------------------------------------------------------------------| -| =f= ... | fetch (issue sources): =s= pick source, =o= open issues, =p= by project, =f= filter | +| =f= ... | fetch (issue sources): =s= pick source, =o= open issues, =p= by project, =f= filter, =i= open issue by | +| | id | |-----------+----------------------------------------------------------------------------------------------------------| | =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, =s= sort, =g= group, =r= reverse order | @@ -225,7 +226,9 @@ A *source* is anything Pearl can fetch from: a Linear favorite, a Custom View, a [local → Linear:Personal] My ICEBOX scratchpad (synced) #+end_example -Pick one and it fetches. List-capable favorites (Custom View / project / cycle / label / user) resolve to the existing filter or view fetch and render into the active file; non-list favorites (issue, document, dashboard, ...) open in the browser instead. Favorites are picker entries, never persisted -- a chosen favorite resolves to a concrete filter/view source before rendering, so refresh re-runs that resolved source and stays stable even if your favorites list later changes. Label and user favorites resolve by id, not by name or email, so renames in Linear don't break a saved fetch. +Pick one and it fetches. List-capable favorites (Custom View / project / cycle / label / user) resolve to the existing filter or view fetch and render into the active file. An issue favorite renders that one issue in the buffer as its own source -- its full subtree (description, comments, drawer), the same as every other source. The remaining non-list favorites (document, dashboard, ...) open in the browser instead. Favorites are picker entries, never persisted -- a chosen favorite resolves to a concrete source before rendering, so refresh re-runs that resolved source and stays stable even if your favorites list later changes. Label and user favorites resolve by id, not by name or email, so renames in Linear don't break a saved fetch. + +To open a single issue you don't have favorited, use =pearl-open-issue-by-id= (=C-; L f i=, or =i= in the transient). Type an identifier like =ENG-123= (or a raw issue id) and Pearl fetches and renders that one issue. Handy when someone hands you an issue id and you'd rather have it in Pearl than a browser tab. A local view you've published to Linear (see [[*Publishing a local view as a Linear view]] below) renders as =[local → Linear:<Team or Personal>] Name= so you can see at a glance that it's published and to which scope. The arrow reads "this local view is mirrored there." The local view is the source of truth: picking it runs *your local filter*, not the Linear mirror, so editing the local view and running it shows your edits even before you publish them. To run the server-side Linear view instead, use =pearl-run-linear-view=. Plain =[local]= entries are local-only. =[local → Linear:?]= means the entry is published but Pearl couldn't resolve the team id (deleted or renamed on Linear) -- running still works, the label is just flagging stale scope metadata. @@ -289,30 +292,32 @@ Linear stores a view's filter as an =and=/=or= tree that's richer than Pearl's A *** Fetching and refreshing -| Command | What it does | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-pick-source= | Pick a Linear favorite or local view and fetch it | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-list-issues= | Fetch your open issues | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-list-issues-by-project= | Fetch every open issue in a chosen project (all assignees) | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-list-issues-filtered= | Build an ad-hoc issue filter interactively | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-run-linear-view= | Run a Linear Custom View server-side | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-run-local-view= | Run a named local view from =pearl-local-views= | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-publish-local-view= | Publish (or update) a local view as a Linear Custom View | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-publish-current-view= | Publish the current buffer's local view (reads =#+LINEAR-SOURCE=) | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-delete-local-view= | Delete a local view (and optionally its linked Linear view) | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-refresh-current-view= | Re-run the source recorded in the active file | -|--------------------------------+-------------------------------------------------------------------| -| =pearl-refresh-current-issue= | Re-fetch the issue at point | -|--------------------------------+-------------------------------------------------------------------| +| Command | What it does | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-pick-source= | Pick a Linear favorite or local view and fetch it | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-list-issues= | Fetch your open issues | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-list-issues-by-project= | Fetch every open issue in a chosen project (all assignees) | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-list-issues-filtered= | Build an ad-hoc issue filter interactively | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-open-issue-by-id= | Open one issue by its identifier (ENG-123) or id, rendered in-buffer | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-run-linear-view= | Run a Linear Custom View server-side | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-run-local-view= | Run a named local view from =pearl-local-views= | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-publish-local-view= | Publish (or update) a local view as a Linear Custom View | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-publish-current-view= | Publish the current buffer's local view (reads =#+LINEAR-SOURCE=) | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-delete-local-view= | Delete a local view (and optionally its linked Linear view) | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-refresh-current-view= | Re-run the source recorded in the active file | +|--------------------------------+----------------------------------------------------------------------| +| =pearl-refresh-current-issue= | Re-fetch the issue at point | +|--------------------------------+----------------------------------------------------------------------| Running a Linear Custom View (via =pearl-run-linear-view= or by picking a favorited view in =pearl-pick-source=) mirrors the view as Linear shows it: its own filter runs server-side, its configured sort order is reproduced, and its "completed issues" display setting is honored. A view set to hide completed issues shows only open issues in Pearl too; a view set to show completed issues from the past day/week/month/quarter/year shows open issues plus those completed or canceled within that window (the windows are fixed-day approximations of Linear's). Refreshing the view recomputes the window relative to the refresh time. @@ -1404,11 +1404,21 @@ both normalize to `:kind label' so the dispatch collapses them." (cdr (assoc 'identifier (cdr (assoc 'issue node)))))) base))) +(defun pearl--issue-source (id identifier) + "Build a `:type issue' source plist for the single issue ID/IDENTIFIER. +IDENTIFIER is the human key (ENG-1) and becomes the source name so the buffer +title and `#+LINEAR-SOURCE' read naturally; it falls back to ID when absent, so +`:name' is never nil. Mirrors the other source shapes (`:type view', +`:type filter') so the issue flows through the same fetch/render/refresh path." + (list :type 'issue :id id :identifier identifier + :name (or identifier id))) + (defun pearl--favorite->source (fav) "Resolve a normalized FAV into a runnable source plist for the existing query/render path, or a browser action `(:browser t :url U :title T :kind K)' -when the favorite is not a list source in v1 (issue / team / document / -dashboard / other)." +when the favorite is not a list source in v1 (team / document / dashboard / +other). An issue favorite resolves to a `:type issue' source so a starred +issue renders in the buffer like every other source." (let ((kind (plist-get fav :kind)) (title (plist-get fav :title)) (url (plist-get fav :url)) @@ -1423,6 +1433,7 @@ dashboard / other)." :filter (list :label-id id :open t))) ('user (list :type 'filter :name title :filter (list :assignee-id id :open t))) + ('issue (pearl--issue-source id (plist-get fav :identifier))) (_ (list :browser t :url url :title title :kind kind))))) (defconst pearl--favorites-query @@ -4920,6 +4931,9 @@ missing setup." (pearl--open-favorite-url source)) ((eq (plist-get source :type) 'view) (pearl--run-view-source source)) + ((eq (plist-get source :type) 'issue) + (pearl--progress "Opening %s..." (pearl--source-name source)) + (pearl--fetch-and-render-issue (plist-get source :id))) (t (let ((filter (plist-get source :filter))) (pearl--validate-issue-filter filter) @@ -4929,6 +4943,44 @@ missing setup." (lambda (result) (pearl--render-query-result result source)) (pearl--sort->order-by (plist-get source :sort)))))))))))) +(defun pearl--fetch-and-render-issue (ref) + "Fetch the issue named by REF and render it as the active source. +REF is a Linear issue id (UUID) or its human identifier (ENG-1); Linear's +`issue(id:)' accepts either. On success the single node is rendered through +the shared `pearl--render-query-result' boundary -- the same full-replace path +list-issues and views use -- tagged with a `:type issue' source built from the +node's own id and identifier, so refresh and the `#+LINEAR-SOURCE' header work +exactly as they do for every other source. A missing or errored fetch reports +and leaves the buffer untouched." + (pearl--fetch-issue-async + ref + (lambda (result) + (pcase result + (:error + (message "Linear returned an error fetching %s" ref)) + (:missing + (message "Issue %s not found on Linear (deleted, or no access)" ref)) + (raw + (pearl--render-query-result + (pearl--make-query-result 'ok :issues (list raw)) + (pearl--issue-source (cdr (assoc 'id raw)) + (cdr (assoc 'identifier raw))))))))) + +;;;###autoload +(defun pearl-open-issue-by-id (ref) + "Open a single Linear issue by its identifier or id and render it in Pearl. +Prompts for REF -- a human identifier like ENG-123 (or a raw issue id) -- then +fetches that one issue and renders it in the active file as its own source, the +same shape every other source produces. Useful when someone hands you an issue +id and you want it in Pearl rather than a browser." + (interactive (list (read-string "Issue identifier (e.g. ENG-123): "))) + (pearl--require-account-context) + (let ((trimmed (string-trim ref))) + (when (string-empty-p trimmed) + (user-error "No issue identifier given")) + (pearl--progress "Opening %s..." trimmed) + (pearl--fetch-and-render-issue trimmed))) + ;;; Copy down: save a Linear view as a local view (see the reverse-compile above) (defun pearl--customview-filterdata-async (view-id callback) @@ -7176,6 +7228,20 @@ commands. Errors if no source is recorded." (plist-get source :sort) (pearl--view-completed-filter (plist-get source :show-completed)))) + ('issue + (pearl--progress "Refreshing %s..." (pearl--source-name source)) + (pearl--fetch-issue-async + (plist-get source :id) + (lambda (result) + (pcase result + (:error + (message "Linear returned an error refreshing %s" + (pearl--source-name source))) + (:missing + (message "Issue %s is no longer on Linear (deleted, or no access)" + (pearl--source-name source))) + (raw + (funcall merge (pearl--make-query-result 'ok :issues (list raw)))))))) (_ (user-error "Unknown Linear source type: %s" (plist-get source :type))))))) ;;; Interactive sort/order of the active view @@ -8366,7 +8432,8 @@ body stay." ("l" "open default view" pearl-open-default-view) ("m" "my open issues" pearl-list-issues) ("p" "by project" pearl-list-issues-by-project) - ("f" "build a filter" pearl-list-issues-filtered)] + ("f" "build a filter" pearl-list-issues-filtered) + ("i" "open issue by id" pearl-open-issue-by-id)] ["Views" ("V" "create local view" pearl-create-local-view) ("e" "edit local view" pearl-edit-local-view) @@ -8423,6 +8490,7 @@ body stay." (define-key map "o" (cons "my open issues" #'pearl-list-issues)) (define-key map "p" (cons "by project" #'pearl-list-issues-by-project)) (define-key map "f" (cons "build a filter" #'pearl-list-issues-filtered)) + (define-key map "i" (cons "open issue by id" #'pearl-open-issue-by-id)) (define-key map "c" (cons "all comments for issue" #'pearl-fetch-all-comments)) map) "Pearl issue-fetch commands; a sub-keymap of `pearl-prefix-map'. diff --git a/tests/test-pearl-favorites.el b/tests/test-pearl-favorites.el index 3f1452f..cb229b6 100644 --- a/tests/test-pearl-favorites.el +++ b/tests/test-pearl-favorites.el @@ -160,13 +160,17 @@ string -- the favorites query selects customView.name for exactly this." (let ((s (pearl--favorite->source (test-pearl-fav--norm 'user "user-1" "Vrezh")))) (should (equal '(:assignee-id "user-1" :open t) (plist-get s :filter))))) -(ert-deftest test-pearl-favorite->source-issue-is-browser () - "Issue favorites are browser-only in v1 (no source)." +(ert-deftest test-pearl-favorite->source-issue-renders-in-buffer () + "An issue favorite resolves to a `:type issue' source, rendered in-buffer +\(not the v1 browser punt). The identifier becomes the source name." (let ((s (pearl--favorite->source (append (test-pearl-fav--norm 'issue "iss-1" "Some bug" "https://linear.app/x/issue/ENG-1") (list :identifier "ENG-1"))))) - (should (plist-get s :browser)) - (should (string= "https://linear.app/x/issue/ENG-1" (plist-get s :url))))) + (should-not (plist-get s :browser)) + (should (eq 'issue (plist-get s :type))) + (should (string= "iss-1" (plist-get s :id))) + (should (string= "ENG-1" (plist-get s :identifier))) + (should (string= "ENG-1" (plist-get s :name))))) (ert-deftest test-pearl-favorite->source-other-is-browser () "An unknown-kind favorite is browser-only." diff --git a/tests/test-pearl-menu.el b/tests/test-pearl-menu.el index d939a9e..1795a41 100644 --- a/tests/test-pearl-menu.el +++ b/tests/test-pearl-menu.el @@ -79,6 +79,8 @@ menu entry that still points at it fails here." pearl-set-default-view ;; fetch the full comment thread for the issue at point pearl-fetch-all-comments + ;; single-issue source: open one issue by identifier + pearl-open-issue-by-id ;; view-shaping commands added to the Views group pearl-set-sort pearl-set-grouping diff --git a/tests/test-pearl-single-issue.el b/tests/test-pearl-single-issue.el new file mode 100644 index 0000000..a686ea5 --- /dev/null +++ b/tests/test-pearl-single-issue.el @@ -0,0 +1,117 @@ +;;; test-pearl-single-issue.el --- Tests for the single-issue source -*- 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 single-issue source: `pearl--issue-source' (id/identifier -> +;; source plist), the `#+LINEAR-SOURCE' round-trip for that plist, +;; `pearl--fetch-and-render-issue' (fetch one issue and render it like every +;; other source), and `pearl-open-issue-by-id' (the jump-by-identifier command). +;; The async fetch and the render boundary are stubbed at their seams. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +;;; pearl--issue-source + +(ert-deftest test-pearl-issue-source-builds-plist () + "An id + identifier builds a `:type issue' source named by the identifier." + (let ((s (pearl--issue-source "u1" "ENG-7"))) + (should (eq 'issue (plist-get s :type))) + (should (string= "u1" (plist-get s :id))) + (should (string= "ENG-7" (plist-get s :identifier))) + (should (string= "ENG-7" (plist-get s :name))))) + +(ert-deftest test-pearl-issue-source-name-falls-back-to-id () + "With no identifier the source name falls back to the id, never nil." + (let ((s (pearl--issue-source "u1" nil))) + (should (string= "u1" (plist-get s :name))))) + +(ert-deftest test-pearl-issue-source-round-trips-through-header () + "The issue source survives `#+LINEAR-SOURCE' serialization and read-back." + (let* ((s (pearl--issue-source "u1" "ENG-7")) + (back (car (read-from-string (pearl--linear-source-string s))))) + (should (equal s back)))) + +;;; pearl--fetch-and-render-issue + +(ert-deftest test-pearl-fetch-and-render-issue-renders-one () + "A fetched node renders through the shared query-result boundary as one issue, +tagged with an issue source built from the node's own id and identifier." + (let ((node '((id . "u1") (identifier . "ENG-7") (title . "A bug"))) + captured) + (cl-letf (((symbol-function 'pearl--fetch-issue-async) + (lambda (_ref cb) (funcall cb node))) + ((symbol-function 'pearl--render-query-result) + (lambda (result source) (setq captured (list result source))))) + (pearl--fetch-and-render-issue "ENG-7") + (should captured) + (let ((result (nth 0 captured)) + (source (nth 1 captured))) + (should (equal (list node) (pearl--query-result-issues result))) + (should (eq 'issue (plist-get source :type))) + (should (string= "u1" (plist-get source :id))) + (should (string= "ENG-7" (plist-get source :identifier))))))) + +(ert-deftest test-pearl-fetch-and-render-issue-missing-does-not-render () + "A `:missing' result reports and renders nothing (no buffer rewrite)." + (let (rendered) + (cl-letf (((symbol-function 'pearl--fetch-issue-async) + (lambda (_ref cb) (funcall cb :missing))) + ((symbol-function 'pearl--render-query-result) + (lambda (&rest _) (setq rendered t))) + ((symbol-function 'message) (lambda (&rest _) nil))) + (pearl--fetch-and-render-issue "ENG-404") + (should-not rendered)))) + +(ert-deftest test-pearl-fetch-and-render-issue-error-does-not-render () + "An `:error' result reports and renders nothing." + (let (rendered) + (cl-letf (((symbol-function 'pearl--fetch-issue-async) + (lambda (_ref cb) (funcall cb :error))) + ((symbol-function 'pearl--render-query-result) + (lambda (&rest _) (setq rendered t))) + ((symbol-function 'message) (lambda (&rest _) nil))) + (pearl--fetch-and-render-issue "ENG-7") + (should-not rendered)))) + +;;; pearl-open-issue-by-id + +(ert-deftest test-pearl-open-issue-by-id-forwards-trimmed-ref () + "The command trims the typed ref and forwards it to the fetch/render path." + (let (got) + (cl-letf (((symbol-function 'pearl--require-account-context) #'ignore) + ((symbol-function 'pearl--progress) (lambda (&rest _) nil)) + ((symbol-function 'pearl--fetch-and-render-issue) + (lambda (ref) (setq got ref)))) + (pearl-open-issue-by-id " ENG-7 ") + (should (string= "ENG-7" got))))) + +(ert-deftest test-pearl-open-issue-by-id-rejects-empty () + "An empty/whitespace identifier is a `user-error', not a fetch." + (let (fetched) + (cl-letf (((symbol-function 'pearl--require-account-context) #'ignore) + ((symbol-function 'pearl--fetch-and-render-issue) + (lambda (&rest _) (setq fetched t)))) + (should-error (pearl-open-issue-by-id " ") :type 'user-error) + (should-not fetched)))) + +(provide 'test-pearl-single-issue) +;;; test-pearl-single-issue.el ends here |
