From dcfecf3e4f46c920eb77ca9b3169d0719fa0b9fd Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 27 May 2026 22:54:43 -0500 Subject: feat(sources): favorites, views, and filters via pick-source Pearl now treats Linear sources (favorites, custom views, ad-hoc filters, saved queries) as one surface. `pearl-pick-source` is the unified picker: it lists every Linear favorite alongside every locally saved query, and dispatches by kind. View / project / cycle / label / user favorites resolve to runnable filter or view sources; issue / document / dashboard favorites open in the browser since they aren't lists. The filter compiler grows id-based forms (`:label-id` and `:assignee-id`), so favorites that name a Linear entity (a user, a label) compile to rename-proof queries rather than fragile name matching. The ad-hoc builder gets a matching member-assignee option ("me / member / any"), so a one-off "what's a teammate working on?" doesn't require building a saved query first. Two pre-existing builder bugs surfaced during verify and got fixed in the same pass. The builder previously passed display names straight to the compiler, but `:team` compiles to `team.key.eq` and `:project` to `project.id.eq`, so Linear rejected every builder run that pinned a team or project. Now the builder resolves the picked name to its key / id before assembling. And `pearl-list-issues-by-project` previously injected `:assignee :me` into the filter, hiding every teammate's work in the project. It now returns the whole project. The save prompt on the builder was rewritten so the local-only nature is unambiguous, and the README grew a Sources section plus a keymap-table entry for `f s`. --- README.org | 23 +++- docs/issue-sources-spec.org | 9 +- pearl.el | 298 ++++++++++++++++++++++++++++++++++++++---- tests/test-pearl-adhoc.el | 6 + tests/test-pearl-commands.el | 36 ++++- tests/test-pearl-favorites.el | 237 +++++++++++++++++++++++++++++++++ tests/test-pearl-filter.el | 24 ++++ tests/test-pearl-keymap.el | 3 +- 8 files changed, 597 insertions(+), 39 deletions(-) create mode 100644 tests/test-pearl-favorites.el diff --git a/README.org b/README.org index c5b01b0..0c78722 100644 --- a/README.org +++ b/README.org @@ -168,7 +168,7 @@ The hot-path commands sit one key under the prefix; the rest are grouped into su | =s= / =S= | save the issue at point / save every issue in the file | | =d= | edit the description | | =m= | open the full transient menu | -| =f= ... | fetch: =o= open issues, =p= by project, =f= filter, =v= view, =q= saved query | +| =f= ... | fetch: =s= pick source, =o= open issues, =p= by project, =f= filter, =v= view, =q= saved query | | =e= ... | edit: =d= description, =s= state, =a= assignee, =l= labels, =c= comment | | =c= ... | create: =t= issue, =c= comment | | =k= ... | delete: =t= issue, =c= comment | @@ -183,19 +183,36 @@ To reach the map outside a Pearl buffer, bind it globally as well: With =which-key=, each step shows a labeled menu. Every command is also available through =M-x=. +*** Sources + +A *source* is anything Pearl can fetch from: a Linear favorite, a Custom View, a project / cycle / label / user, a local saved query, or an ad-hoc filter. The everyday front door is =pearl-pick-source= (=C-; L f s=, or =P= in the transient), which lists all your Linear favorites first (in their Linear sort order) and then your local saved queries, each tagged by kind: + +#+begin_example + [view] Active sprint bugs + [project] Orchestration Dashboard + [user] Vrezh Mikayelyan + [label] security + [saved] My open work +#+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. + +If the favorites fetch fails (network/auth/transport), the picker still offers any local saved queries -- a failed fetch is not the same as "no favorites." With neither favorites nor saved queries, =pearl-pick-source= refuses with a clear message naming the missing setup. + *** Fetching and refreshing | Command | What it does | |------------------------------+--------------------------------------------------| +| =pearl-pick-source= | Pick a Linear favorite or local saved query and fetch it | | =pearl-list-issues= | Fetch your open issues | -| =pearl-list-issues-by-project= | Fetch open issues in a chosen project | +| =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-view= | Run a Linear Custom View server-side | | =pearl-run-saved-query= | Run a named local query from =pearl-saved-queries= | | =pearl-refresh-current-view= | Re-run the source recorded in the active file | | =pearl-refresh-current-issue= | Re-fetch the issue at point | -Ad-hoc filtering starts with a team, then completes states, projects, and labels from that team's actual Linear values. Saved queries are local Lisp data: +Ad-hoc filtering starts with a team, then completes states, projects, and labels from that team's actual Linear values. The assignee prompt offers =me=, a specific =member= (resolved to a user id), or =any= (no scoping). Saved queries are local Lisp data: #+begin_src emacs-lisp (setq pearl-saved-queries diff --git a/docs/issue-sources-spec.org b/docs/issue-sources-spec.org index 101e13a..a4374c3 100644 --- a/docs/issue-sources-spec.org +++ b/docs/issue-sources-spec.org @@ -37,7 +37,9 @@ Each =Favorite= node carries =type= (the dispatch key), =title= (a ready display : query { favorites { nodes { id type title url sortOrder : customView { id } project { id } cycle { id } -: label { id } user { id } issue { id identifier } team { id } } } } +: label { id } user { id } issue { id identifier } +: team { id } projectLabel { id } document { id } dashboard { id } } +: pageInfo { hasNextPage endCursor } } } Introspection of the =Favorite= type confirmed the favoritable entity fields: =issue=, =project=, =cycle=, =customView=, =label=, =projectLabel=, =user=, =team=, =document=, =initiative=, =dashboard=, =pullRequest=, =release=, =releaseNote=, =facet=, =predefinedViewType=, =projectTab=, =customer=, plus convenience fields =title=, =url=, =detail=, =color=, =icon=, =folderName=, =owner=, =sortOrder=. Craig's current 9 favorites span customView / project / issue; the schema supports the rest. @@ -49,12 +51,11 @@ Pagination: the =favorites= connection is paginated like other Linear connection | favorite =type= | required API fields | v1 action | persisted source | |----------------------+------------------------+--------------------+----------------------------------------------------| -| =customView= | =customView.id=, title | run the view | =(:type view :name T :view-id ID)= | +| =customView= | =customView.id=, title | run the view | =(:type view :name T :id ID :url URL)= | | =project= | =project.id=, title | filter | =(:type filter :name T :filter (:project ID :open t))= | | =cycle= | =cycle.id=, title | filter | =(:type filter :name T :filter (:cycle ID :open t))= | -| =label= / =projectLabel= | =label.id=, title | filter | =(:type filter :name T :filter (:label-id ID :open t))= | +| =label= | =label.id=, title | filter | =(:type filter :name T :filter (:label-id ID :open t))= | | =user= | =user.id=, title | filter | =(:type filter :name T :filter (:assignee-id ID :open t))= | -| =team= | =team.id=, title | filter | =(:type filter :name T :filter (:team ID :open t))= | | =issue= | =issue.identifier=, url | browser-only (HP2) | none — opens =url=, renders no source | | anything else | =url=, title | browser-only | none — opens =url= | diff --git a/pearl.el b/pearl.el index 8f7fa3c..ee01af1 100644 --- a/pearl.el +++ b/pearl.el @@ -558,10 +558,13 @@ first four are the open ones.") "Alist mapping priority symbols to Linear's numeric priority values.") (defconst pearl--filter-keys - '(:assignee :open :state :state-type :project :team :labels :priority :cycle - :sort :order) + '(:assignee :assignee-id :open :state :state-type :project :team :labels + :label-id :priority :cycle :sort :order) "Keys recognized in an issue-filter authoring plist. -`:sort' and `:order' steer result ordering, not the `IssueFilter' itself.") +`:sort' and `:order' steer result ordering, not the `IssueFilter' itself. +`:assignee-id' and `:label-id' are the rename-proof id-based forms used when +resolving a favorite to a filter; the name/email forms (`:assignee', `:labels') +remain for the ad-hoc builder where the user is choosing live.") (defun pearl--eq (value) "Return a Linear comparator alist matching VALUE exactly." @@ -637,6 +640,14 @@ the ordering keys `:sort' / `:order'." (let ((cycle (plist-get plist :cycle))) (when cycle (push (cons "cycle" (list (cons "id" (pearl--eq cycle)))) filter))) + (let ((assignee-id (plist-get plist :assignee-id))) + (when assignee-id + (push (cons "assignee" (list (cons "id" (pearl--eq assignee-id)))) filter))) + (let ((label-id (plist-get plist :label-id))) + (when label-id + (push (cons "labels" + (list (cons "some" (list (cons "id" (pearl--in (list label-id))))))) + filter))) (nreverse filter))) (defun pearl--validate-issue-filter (plist) @@ -664,7 +675,7 @@ needs team context) is resolved upstream, not here." (let ((order (plist-get plist :order))) (when (and order (not (memq order '(asc desc)))) (user-error ":order must be `asc' or `desc', got %S" order))) - (dolist (key '(:state :project :team :cycle)) + (dolist (key '(:state :project :team :cycle :assignee-id :label-id)) (let ((val (plist-get plist key))) (when (and (stringp val) (string-empty-p val)) (user-error "%s must not be an empty string" key)))) @@ -724,6 +735,103 @@ CONNECTION is an alist with a `nodes' key holding a vector, a list, or nil." (list :id (cdr (assoc 'id label)) :name (cdr (assoc 'name label)))) (pearl--node-list raw))) +;;; Favorites layer (spec: docs/issue-sources-spec.org) + +(defun pearl--favorite-entity-id (node entity-key) + "Pick ENTITY-KEY's id from favorite NODE, or nil if absent." + (cdr (assoc 'id (cdr (assoc entity-key node))))) + +(defun pearl--normalize-favorite (node) + "Normalize a Linear Favorite NODE alist to a typed plist for dispatch. +Returns (:kind KIND :title T :url U :sort-order N :id ID [:identifier IDENT]). +KIND is one of `view', `project', `cycle', `label', `user', `team', `issue', +`document', `dashboard', or `other' (a favorite type Pearl cannot dispatch into +a list source). For an issue favorite, `:identifier' carries the human key +like ENG-1; everything else carries only `:id'. =label= and =projectLabel= +both normalize to `:kind label' so the dispatch collapses them." + (let* ((type (cdr (assoc 'type node))) + (title (or (cdr (assoc 'title node)) "")) + (url (cdr (assoc 'url node))) + (sort-order (cdr (assoc 'sortOrder node))) + (kind+id + (pcase type + ("customView" (cons 'view (pearl--favorite-entity-id node 'customView))) + ("project" (cons 'project (pearl--favorite-entity-id node 'project))) + ("cycle" (cons 'cycle (pearl--favorite-entity-id node 'cycle))) + ("label" (cons 'label (pearl--favorite-entity-id node 'label))) + ("projectLabel" (cons 'label (pearl--favorite-entity-id node 'projectLabel))) + ("user" (cons 'user (pearl--favorite-entity-id node 'user))) + ("team" (cons 'team (pearl--favorite-entity-id node 'team))) + ("issue" (cons 'issue (pearl--favorite-entity-id node 'issue))) + ("document" (cons 'document (pearl--favorite-entity-id node 'document))) + ("dashboard" (cons 'dashboard (pearl--favorite-entity-id node 'dashboard))) + (_ (cons 'other nil)))) + (base (list :kind (car kind+id) :title title :url url + :sort-order sort-order :id (cdr kind+id)))) + (if (eq (car kind+id) 'issue) + (append base (list :identifier + (cdr (assoc 'identifier (cdr (assoc 'issue node)))))) + base))) + +(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)." + (let ((kind (plist-get fav :kind)) + (title (plist-get fav :title)) + (url (plist-get fav :url)) + (id (plist-get fav :id))) + (pcase kind + ('view (list :type 'view :name title :id id :url url)) + ('project (list :type 'filter :name title + :filter (list :project id :open t))) + ('cycle (list :type 'filter :name title + :filter (list :cycle id :open t))) + ('label (list :type 'filter :name title + :filter (list :label-id id :open t))) + ('user (list :type 'filter :name title + :filter (list :assignee-id id :open t))) + (_ (list :browser t :url url :title title :kind kind))))) + +(defconst pearl--favorites-query + "query Favorites($after: String) { + favorites(first: 100, after: $after) { + nodes { id type title url sortOrder + customView { id } project { id } cycle { id } + label { id } user { id } issue { id identifier } + team { id } projectLabel { id } document { id } dashboard { id } } + pageInfo { hasNextPage endCursor } } }" + "GraphQL query for the viewer's favorites; paged with first/after.") + +(defun pearl--favorites-async (callback) + "Fetch the viewer's favorites and call CALLBACK with a list of normalized +favorite plists (Linear sort-order preserved). Paged via first/after; on a +transport/GraphQL error, CALLBACK receives whatever was collected so far so the +picker can fall back to local saved queries rather than hanging." + (let (acc) + (cl-labels + ((collect (data) + (let* ((favs (cdr (assoc 'favorites (cdr (assoc 'data data))))) + (nodes (append (cdr (assoc 'nodes favs)) nil)) + (page (cdr (assoc 'pageInfo favs)))) + (dolist (n nodes) + (push (pearl--normalize-favorite n) acc)) + (if (eq (cdr (assoc 'hasNextPage page)) t) + (pearl--graphql-request-async + pearl--favorites-query + (list (cons "after" (cdr (assoc 'endCursor page)))) + #'collect + (lambda (&rest _) (funcall callback (nreverse acc)))) + (funcall callback (nreverse acc)))))) + (pearl--graphql-request-async + pearl--favorites-query nil #'collect + (lambda (&rest _) + ;; HP5: a fetch error is not "no favorites" -- message it so the + ;; picker's fallback to saved queries doesn't masquerade as success. + (message "Pearl: favorites fetch failed; showing saved queries only") + (funcall callback (nreverse acc))))))) + (defun pearl--normalize-comment (raw) "Normalize a Linear comment alist RAW to a plist. The author falls back through user, then botActor, then externalUser, because @@ -3125,6 +3233,99 @@ active file with the query recorded as the source." (lambda (result) (pearl--render-query-result result source)) (pearl--sort->order-by sort))))) +;;; pearl-pick-source -- the unified source picker (favorites + saved queries) + +(defun pearl--pick-source-candidates (favorites saved-queries) + "Build the picker's candidate alist `((DISPLAY . SOURCE-OR-ACTION) ...)'. +FAVORITES is a list of normalized favorite plists; SAVED-QUERIES is the +`pearl-saved-queries' alist. Favorites come first in ascending `:sort-order'; +saved queries follow alphabetically. Each DISPLAY is `[KIND] TITLE' and is +made unique by appending ` (#N)' on a true collision -- dispatch reads +SOURCE-OR-ACTION, not the display string." + (let ((fav-sorted (sort (copy-sequence favorites) + (lambda (a b) + (< (or (plist-get a :sort-order) 0.0) + (or (plist-get b :sort-order) 0.0))))) + (saved-sorted (sort (copy-sequence saved-queries) + (lambda (a b) (string< (car a) (car b))))) + (alist nil) + (seen (make-hash-table :test 'equal))) + (cl-labels + ((push-unique + (display source) + (let ((d display) (n 2)) + (while (gethash d seen) + (setq d (format "%s (#%d)" display n)) + (cl-incf n)) + (puthash d t seen) + (push (cons d source) alist)))) + (dolist (fav fav-sorted) + (let* ((kind (plist-get fav :kind)) + (title (plist-get fav :title)) + (display (format "[%s] %s" (symbol-name kind) title)) + (source (pearl--favorite->source fav))) + (push-unique display source))) + (dolist (sq saved-sorted) + (let* ((name (car sq)) + (spec (cdr sq)) + (display (format "[saved] %s" name)) + (source (list :type 'filter :name name + :filter (plist-get spec :filter) + :sort (plist-get spec :sort) + :order (plist-get spec :order)))) + (push-unique display source)))) + (nreverse alist))) + +(defun pearl--open-favorite-url (action) + "Open ACTION's `:url' in the browser, or refuse with a clear `user-error' +when the URL is nil or empty. Messages \"Opened KIND favorite in browser: TITLE\" +on success so the user sees what happened." + (let ((url (plist-get action :url)) + (kind (plist-get action :kind)) + (title (plist-get action :title))) + (unless (and url (stringp url) (not (string-empty-p url))) + (user-error "Favorite has no URL to open")) + (browse-url url) + (message "Opened %s favorite in browser: %s" + (if kind (symbol-name kind) "linear") title))) + +;;;###autoload +(defun pearl-pick-source () + "Pick a Linear favorite or local saved query and fetch it into the active file. +Favorites are listed first in Linear's sort order, then local saved queries +alphabetically. Non-list favorites (issue, document, ...) open in the browser +instead. On a favorites-fetch failure the picker still offers any local saved +queries -- favorites-failure is not the same user state as \"no favorites\". +With neither favorites nor saved queries, signals a `user-error' naming the +missing setup." + (interactive) + (pearl--progress "Fetching favorites...") + (let ((saved pearl-saved-queries)) + (pearl--favorites-async + (lambda (favorites) + (let ((cands (pearl--pick-source-candidates favorites saved))) + (unless cands + (user-error + "No favorites or saved queries -- star views in Linear, or set `pearl-saved-queries'")) + (let* ((display (completing-read "Source: " (mapcar #'car cands) nil t)) + (source (cdr (assoc display cands)))) + (cond + ((plist-get source :browser) + (pearl--open-favorite-url source)) + ((eq (plist-get source :type) 'view) + (pearl--progress "Running view %s..." (plist-get source :name)) + (pearl--query-view-async + (plist-get source :id) + (lambda (result) (pearl--render-query-result result source)))) + (t + (let ((filter (plist-get source :filter))) + (pearl--validate-issue-filter filter) + (pearl--progress "Running %s..." (plist-get source :name)) + (pearl--query-issues-async + (pearl--build-issue-filter filter) + (lambda (result) (pearl--render-query-result result source)) + (pearl--sort->order-by (plist-get source :sort)))))))))))) + (defun pearl--assemble-filter (team open state project labels assignee) "Assemble a filter authoring plist from the chosen dimensions. The non-nil of TEAM, OPEN, STATE, PROJECT, LABELS, and ASSIGNEE appear (an @@ -3132,7 +3333,8 @@ empty LABELS list is dropped), feeding `pearl--build-issue-filter' with just what the user set." (append (when team (list :team team)) - (when assignee (list :assignee assignee)) + (cond ((eq assignee :me) (list :assignee :me)) + ((stringp assignee) (list :assignee-id assignee))) (when open (list :open t)) (when state (list :state state)) (when project (list :project project)) @@ -3141,7 +3343,9 @@ with just what the user set." (defun pearl--read-filter-interactively () "Build a filter plist by completing over the chosen team's fetched dimensions. Picks a team first (scoping the rest), then offers open-only, state, project, -labels, and assignee. Empty answers drop the dimension." +labels, and assignee. The assignee prompt offers `me' (the viewer), +`member' (a specific teammate, resolved to an id), or `any' (no scoping). +Empty answers drop the dimension." (let* ((teams (pearl--all-teams)) (team-name (completing-read "Team (empty for any): " (mapcar (lambda (tm) (cdr (assoc 'name tm))) teams) @@ -3161,16 +3365,36 @@ labels, and assignee. Empty answers drop the dimension." "Project (empty for any): " (pearl--team-collection-names 'projects team-id) nil nil))) - (unless (string-empty-p p) p)))) + ;; The :project filter compiles to project.id.eq, so + ;; resolve the picked name to its id before passing. + (unless (string-empty-p p) + (pearl--resolve-team-id 'projects p team-id))))) (labels (and team-id (completing-read-multiple "Labels (comma-separated, empty for none): " (pearl--team-collection-names 'labels team-id)))) - (assignee (pcase (completing-read "Assignee (me / any): " '("me" "any") nil t "any") - ("me" :me) - (_ nil)))) + (assignee (let ((choice (completing-read + "Assignee (me / member / any): " + '("me" "member" "any") nil t "any"))) + (pcase choice + ("me" :me) + ("member" + (and team-id + (let ((name (completing-read + "Member: " + (pearl--team-collection-names 'members team-id) + nil t))) + (unless (string-empty-p name) + (pearl--resolve-team-id 'members name team-id))))) + (_ nil))))) + ;; The :team filter compiles to team.key.eq, so look the picked name up + ;; in the teams alist and pass its key (not the display name). (pearl--assemble-filter - (and (not (string-empty-p team-name)) team-name) + (and (not (string-empty-p team-name)) + (cdr (assoc 'key + (cl-find team-name teams + :key (lambda (tm) (cdr (assoc 'name tm))) + :test #'string=)))) open state project labels assignee))) (defun pearl--save-query (name filter-plist &optional sort order) @@ -3194,8 +3418,8 @@ offers to save the filter as a local query. FILTER-PLIST is the authoring filter; SAVE-NAME, when given, persists it via `pearl--save-query'." (interactive (list (pearl--read-filter-interactively) - (when (y-or-n-p "Save as a local query? ") - (read-string "Query name: ")))) + (when (y-or-n-p "Save this filter locally so you can re-run it (not pushed to Linear)? ") + (read-string "Name for this saved query: ")))) ;; Validate before saving or running, so a bad filter never gets persisted ;; or run -- the command boundary is where the clear error belongs. (pearl--validate-issue-filter filter-plist) @@ -4393,22 +4617,37 @@ a view or has no URL." (browse-url url))) ;;;###autoload -(defun pearl-list-issues (&optional project-id) - "Fetch my open Linear issues into `pearl-org-file-path' and show them. -With PROJECT-ID, narrow to that project. \"Open\" means any workflow state -that is not completed, canceled, or duplicate. Runs asynchronously. +(defun pearl--list-issues-source (project-id project-name) + "Return (AUTHORING . SOURCE) for `pearl-list-issues' given PROJECT-ID/-NAME. +Without PROJECT-ID this is \"my open issues\" (assignee=me). With PROJECT-ID +this is the *whole* project (no assignee lock), named \"Project issues: NAME\" +when PROJECT-NAME is known and \"Project issues\" otherwise." + (let* ((authoring (if project-id + (list :project project-id :open t) + '(:assignee :me :open t))) + (name (cond ((and project-id project-name) + (format "Project issues: %s" project-name)) + (project-id "Project issues") + (t "My open issues")))) + (cons authoring (list :type 'filter :name name :filter authoring)))) + +(defun pearl-list-issues (&optional project-id project-name) + "Fetch open Linear issues into `pearl-org-file-path' and show them. +Without PROJECT-ID, fetches *your* open issues (assignee=me). With PROJECT-ID, +fetches every open issue in that project (no assignee scoping) -- the command +named for a non-assignee dimension does not silently inject assignee=me. +PROJECT-NAME, when known, is used in the source's display name. \"Open\" means +any workflow state that is not completed, canceled, or duplicate. Inclusion is server-side via the issue filter; the state mapping only drives how each issue's state renders as a TODO keyword." (interactive) (pearl--log "Executing pearl-list-issues") (pearl--progress "Fetching issues from Linear...") - (let* ((authoring `(:assignee :me :open t - ,@(when project-id (list :project project-id)))) + (let* ((src (pearl--list-issues-source project-id project-name)) + (authoring (car src)) + (source (cdr src)) (_ (pearl--validate-issue-filter authoring)) - (source (list :type 'filter - :name (if project-id "My open issues in project" "My open issues") - :filter authoring)) (filter (pearl--build-issue-filter authoring))) (pearl--query-issues-async filter @@ -4416,8 +4655,10 @@ how each issue's state renders as a TODO keyword." ;;;###autoload (defun pearl-list-issues-by-project () - "List Linear issues filtered by a selected project. -Uses async API for better performance." + "List every open Linear issue in a chosen project (all assignees). +Picks a team first (or uses `pearl-default-team-id'), then a project, then +fetches via `pearl-list-issues' with no assignee scoping -- \"by project\" +means the project, not your slice of it." (interactive) (let* ((team (if pearl-default-team-id (list (cons 'id pearl-default-team-id)) @@ -4425,11 +4666,12 @@ Uses async API for better performance." (team-id (cdr (assoc 'id team)))) (if team-id (let* ((project (pearl-select-project team-id)) - (project-id (and project (cdr (assoc 'id project))))) + (project-id (and project (cdr (assoc 'id project)))) + (project-name (and project (cdr (assoc 'name project))))) (if project-id (progn - (message "Fetching issues for project: %s" (cdr (assoc 'name project))) - (pearl-list-issues project-id)) + (message "Fetching issues for project: %s" project-name) + (pearl-list-issues project-id project-name)) (message "No project selected"))) (message "No team selected")))) @@ -4864,6 +5106,7 @@ body stay." ("K" "delete comment" pearl-delete-current-comment)]] ["Workspace" ["Fetch" + ("P" "pick source" pearl-pick-source) ("l" "my open issues" pearl-list-issues) ("p" "by project" pearl-list-issues-by-project) ("f" "build a filter" pearl-list-issues-filtered) @@ -4904,6 +5147,7 @@ body stay." (defvar pearl-fetch-map (let ((map (make-sparse-keymap))) + (define-key map "s" (cons "pick source" #'pearl-pick-source)) (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)) diff --git a/tests/test-pearl-adhoc.el b/tests/test-pearl-adhoc.el index 97ac22e..23aad79 100644 --- a/tests/test-pearl-adhoc.el +++ b/tests/test-pearl-adhoc.el @@ -52,6 +52,12 @@ (let ((f (pearl--assemble-filter nil nil nil nil '() nil))) (should-not (plist-member f :labels)))) +(ert-deftest test-pearl-assemble-filter-string-assignee-is-assignee-id () + "A string assignee (resolved member id) lands as :assignee-id, not :assignee." + (let ((f (pearl--assemble-filter nil t nil nil nil "user-1"))) + (should (string= "user-1" (plist-get f :assignee-id))) + (should-not (plist-member f :assignee)))) + ;;; --save-query (ert-deftest test-pearl-save-query-adds-entry () diff --git a/tests/test-pearl-commands.el b/tests/test-pearl-commands.el index ccd39c8..64e38b4 100644 --- a/tests/test-pearl-commands.el +++ b/tests/test-pearl-commands.el @@ -78,16 +78,21 @@ ;;; pearl-list-issues-by-project -(ert-deftest test-pearl-list-issues-by-project-dispatches-with-project-id () - "Selecting a team and project forwards the project id to list-issues." +(ert-deftest test-pearl-list-issues-by-project-dispatches-with-project-id-and-name () + "Selecting a team and project forwards both id and name to list-issues, so +the source can render 'Project issues: '." (let ((pid 'unset) + (pname 'unset) (pearl-default-team-id nil)) (cl-letf (((symbol-function 'pearl-select-team) (lambda () '((id . "t1")))) ((symbol-function 'pearl-select-project) (lambda (_t) '((id . "p1") (name . "Platform")))) - ((symbol-function 'pearl-list-issues) (lambda (project-id) (setq pid project-id)))) + ((symbol-function 'pearl-list-issues) + (lambda (project-id &optional project-name) + (setq pid project-id pname project-name)))) (pearl-list-issues-by-project) - (should (string-equal "p1" pid))))) + (should (string-equal "p1" pid)) + (should (string-equal "Platform" pname))))) (ert-deftest test-pearl-list-issues-by-project-no-team-stops () "With no team selected, list-issues is not called." @@ -149,5 +154,28 @@ (pearl-check-setup) (should-not called)))) +;;; pearl-list-issues source construction (by-project me-lock fix) + +(ert-deftest test-pearl-list-issues-source-default-is-my-open-issues () + "Without a project, pearl-list-issues is me-scoped and named 'My open issues'." + (let ((s (pearl--list-issues-source nil nil))) + (should (equal '(:assignee :me :open t) (car s))) + (should (string= "My open issues" (plist-get (cdr s) :name))))) + +(ert-deftest test-pearl-list-issues-source-project-drops-the-me-lock () + "With a project id (and no name), the filter pins the project only -- no +:assignee key -- and the source name is 'Project issues' (regression against +the old me-locked by-project behavior)." + (let ((s (pearl--list-issues-source "proj-1" nil))) + (should (equal '(:project "proj-1" :open t) (car s))) + (should-not (plist-member (car s) :assignee)) + (should (string= "Project issues" (plist-get (cdr s) :name))))) + +(ert-deftest test-pearl-list-issues-source-project-name-in-display () + "With a project id and name, the source name reads 'Project issues: NAME'." + (let ((s (pearl--list-issues-source "proj-1" "Platform"))) + (should (equal '(:project "proj-1" :open t) (car s))) + (should (string= "Project issues: Platform" (plist-get (cdr s) :name))))) + (provide 'test-pearl-commands) ;;; test-pearl-commands.el ends here diff --git a/tests/test-pearl-favorites.el b/tests/test-pearl-favorites.el new file mode 100644 index 0000000..b47cde6 --- /dev/null +++ b/tests/test-pearl-favorites.el @@ -0,0 +1,237 @@ +;;; test-pearl-favorites.el --- Tests for favorites layer -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings + +;; 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 . + +;;; Commentary: + +;; Tests for the favorites layer (spec: docs/issue-sources-spec.org). Covers +;; `pearl--normalize-favorite' (node -> typed plist), `pearl--favorite->source' +;; (typed plist -> runnable source or browser action), and +;; `pearl--favorites-async' (paged fetch with the request boundary stubbed). + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) +(require 'testutil-request (expand-file-name "testutil-request.el")) +(require 'cl-lib) + +(defun test-pearl-fav--node (type title url sort-order entity-key entity-id &optional identifier) + "Build a favorite NODE alist for TYPE, optionally with an ENTITY-KEY/ID ref. +For an issue node, IDENTIFIER fills the issue's user-facing key." + (let ((node (list (cons 'id (format "fav-%s" type)) + (cons 'type type) + (cons 'title title) + (cons 'url url) + (cons 'sortOrder sort-order)))) + (when entity-key + (push (cons entity-key + (append (list (cons 'id entity-id)) + (when identifier (list (cons 'identifier identifier))))) + node)) + node)) + +;;; normalize per type + +(ert-deftest test-pearl-normalize-favorite-custom-view () + "A customView favorite normalizes to :kind view with the view's id." + (let ((p (pearl--normalize-favorite + (test-pearl-fav--node "customView" "Active bugs" "https://linear.app/x/view/v1" 0.1 + 'customView "view-1")))) + (should (eq 'view (plist-get p :kind))) + (should (string= "Active bugs" (plist-get p :title))) + (should (string= "view-1" (plist-get p :id))))) + +(ert-deftest test-pearl-normalize-favorite-project () + (let ((p (pearl--normalize-favorite + (test-pearl-fav--node "project" "Platform" "https://linear.app/x/project/p1" 0.2 + 'project "proj-1")))) + (should (eq 'project (plist-get p :kind))) + (should (string= "proj-1" (plist-get p :id))))) + +(ert-deftest test-pearl-normalize-favorite-cycle () + (let ((p (pearl--normalize-favorite + (test-pearl-fav--node "cycle" "Sprint 12" "https://linear.app/x/cycle/c1" 0.3 + 'cycle "cyc-1")))) + (should (eq 'cycle (plist-get p :kind))) + (should (string= "cyc-1" (plist-get p :id))))) + +(ert-deftest test-pearl-normalize-favorite-label-and-project-label () + "Both label and projectLabel normalize to :kind label (collapsed for dispatch)." + (let ((a (pearl--normalize-favorite + (test-pearl-fav--node "label" "security" "https://linear.app/x/label/l1" 0.4 + 'label "lbl-1"))) + (b (pearl--normalize-favorite + (test-pearl-fav--node "projectLabel" "milestone" "https://linear.app/x/pl/pl1" 0.5 + 'projectLabel "plbl-1")))) + (should (eq 'label (plist-get a :kind))) + (should (string= "lbl-1" (plist-get a :id))) + (should (eq 'label (plist-get b :kind))) + (should (string= "plbl-1" (plist-get b :id))))) + +(ert-deftest test-pearl-normalize-favorite-user () + (let ((p (pearl--normalize-favorite + (test-pearl-fav--node "user" "Vrezh" "https://linear.app/x/user/u1" 0.6 + 'user "user-1")))) + (should (eq 'user (plist-get p :kind))) + (should (string= "user-1" (plist-get p :id))))) + +(ert-deftest test-pearl-normalize-favorite-issue-carries-identifier () + "An issue favorite carries the user-facing identifier alongside the id." + (let ((p (pearl--normalize-favorite + (test-pearl-fav--node "issue" "Some bug" "https://linear.app/x/issue/ENG-1" 0.7 + 'issue "iss-1" "ENG-1")))) + (should (eq 'issue (plist-get p :kind))) + (should (string= "iss-1" (plist-get p :id))) + (should (string= "ENG-1" (plist-get p :identifier))))) + +(ert-deftest test-pearl-normalize-favorite-unknown-type-is-other () + "An unrecognized favorite type normalizes to :kind other (browser-only)." + (let ((p (pearl--normalize-favorite + (test-pearl-fav--node "release" "v1.2" "https://linear.app/x/release/r1" 0.8 + nil nil)))) + (should (eq 'other (plist-get p :kind))) + (should (null (plist-get p :id))) + (should (string= "https://linear.app/x/release/r1" (plist-get p :url))))) + +;;; favorite->source dispatch + +(defun test-pearl-fav--norm (kind id &optional title url) + "Hand-build a normalized favorite for dispatch tests." + (list :kind kind :title (or title "T") :url (or url "https://x") + :sort-order 0.0 :id id)) + +(ert-deftest test-pearl-favorite->source-view () + "A view favorite dispatches to a (:type view ...) source." + (let ((s (pearl--favorite->source + (test-pearl-fav--norm 'view "view-1" "Active bugs" "https://linear.app/x/v")))) + (should (eq 'view (plist-get s :type))) + (should (string= "Active bugs" (plist-get s :name))) + (should (string= "view-1" (plist-get s :id))))) + +(ert-deftest test-pearl-favorite->source-project () + (let ((s (pearl--favorite->source (test-pearl-fav--norm 'project "proj-1" "Platform")))) + (should (eq 'filter (plist-get s :type))) + (should (string= "Platform" (plist-get s :name))) + (should (equal '(:project "proj-1" :open t) (plist-get s :filter))))) + +(ert-deftest test-pearl-favorite->source-cycle () + (let ((s (pearl--favorite->source (test-pearl-fav--norm 'cycle "cyc-1" "Sprint 12")))) + (should (equal '(:cycle "cyc-1" :open t) (plist-get s :filter))))) + +(ert-deftest test-pearl-favorite->source-label-uses-id () + "Label favorites dispatch to (:label-id ID) -- rename-proof." + (let ((s (pearl--favorite->source (test-pearl-fav--norm 'label "lbl-1" "security")))) + (should (equal '(:label-id "lbl-1" :open t) (plist-get s :filter))))) + +(ert-deftest test-pearl-favorite->source-user-uses-id () + "User favorites dispatch to (:assignee-id ID) -- sidesteps email availability." + (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)." + (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))))) + +(ert-deftest test-pearl-favorite->source-other-is-browser () + "An unknown-kind favorite is browser-only." + (let ((s (pearl--favorite->source (test-pearl-fav--norm 'other nil "Release v1" "https://linear.app/x/r")))) + (should (plist-get s :browser)) + (should (string= "https://linear.app/x/r" (plist-get s :url))))) + +;;; favorites-async (request boundary stubbed) + +(ert-deftest test-pearl-favorites-async-single-page () + "A single-page favorites response is collected into a list of normalized plists." + (testutil-linear-with-response + `((data (favorites + (nodes . ,(vector (test-pearl-fav--node "customView" "V" "https://x/v" 0.1 + 'customView "view-1") + (test-pearl-fav--node "project" "P" "https://x/p" 0.2 + 'project "proj-1"))) + (pageInfo (hasNextPage . :json-false) (endCursor . nil))))) + (let (result) + (pearl--favorites-async (lambda (r) (setq result r))) + (should (= 2 (length result))) + (should (eq 'view (plist-get (nth 0 result) :kind))) + (should (eq 'project (plist-get (nth 1 result) :kind)))))) + +;;; pick-source candidate builder + +(ert-deftest test-pearl-pick-source-candidates-orders-favorites-by-sort-order () + "Favorites are listed first in ascending :sort-order, then saved queries alphabetically." + (let* ((favs (list (test-pearl-fav--norm 'project "p1" "Bravo") ; sort-order 0.0 + (let ((f (test-pearl-fav--norm 'view "v1" "Alpha"))) + (plist-put f :sort-order -1.0)))) ; lower, comes first + (saved '(("My open" :filter (:assignee :me :open t)) + ("All bugs" :filter (:labels ("bug") :open t)))) + (cands (pearl--pick-source-candidates favs saved)) + (labels (mapcar #'car cands))) + (should (equal labels + '("[view] Alpha" + "[project] Bravo" + "[saved] All bugs" + "[saved] My open"))))) + +(ert-deftest test-pearl-pick-source-candidates-dispatch-from-attached-plist () + "Dispatch reads the attached source plist, not the display string." + (let* ((favs (list (test-pearl-fav--norm 'project "proj-1" "Platform"))) + (cands (pearl--pick-source-candidates favs nil)) + (source (cdr (assoc "[project] Platform" cands)))) + (should (eq 'filter (plist-get source :type))) + (should (equal '(:project "proj-1" :open t) (plist-get source :filter))))) + +(ert-deftest test-pearl-pick-source-candidates-disambiguates-duplicates () + "Two candidates that would share a display string are made unique with a suffix." + (let* ((favs (list (test-pearl-fav--norm 'project "p1" "Same") + (test-pearl-fav--norm 'project "p2" "Same"))) + (cands (pearl--pick-source-candidates favs nil)) + (labels (mapcar #'car cands))) + (should (= 2 (length cands))) + ;; both unique + (should (= 2 (length (delete-dups (copy-sequence labels))))))) + +(ert-deftest test-pearl-pick-source-candidates-empty () + "No favorites and no saved queries yields no candidates." + (should (null (pearl--pick-source-candidates nil nil)))) + +;;; open-favorite-url + +(ert-deftest test-pearl-open-favorite-url-with-url-browses () + "A valid URL is handed to browse-url and a message names what opened." + (let (visited) + (cl-letf (((symbol-function 'browse-url) (lambda (u &rest _) (setq visited u)))) + (pearl--open-favorite-url + (list :browser t :url "https://linear.app/x/issue/ENG-1" + :title "Some bug" :kind 'issue)) + (should (string= "https://linear.app/x/issue/ENG-1" visited))))) + +(ert-deftest test-pearl-open-favorite-url-nil-refuses () + "A nil/empty URL refuses cleanly and never calls browse-url." + (let (visited) + (cl-letf (((symbol-function 'browse-url) (lambda (u &rest _) (setq visited u)))) + (should-error (pearl--open-favorite-url + (list :browser t :url nil :title "X" :kind 'other)) + :type 'user-error) + (should-not visited)))) + +(provide 'test-pearl-favorites) +;;; test-pearl-favorites.el ends here diff --git a/tests/test-pearl-filter.el b/tests/test-pearl-filter.el index 6143311..38d254e 100644 --- a/tests/test-pearl-filter.el +++ b/tests/test-pearl-filter.el @@ -189,5 +189,29 @@ "A non-string entry in :labels is a user-error." (should-error (pearl--validate-issue-filter '(:labels ("bug" 7))) :type 'user-error)) +;;; id-based forms (rename-proof; used by favorite-derived filter sources) + +(ert-deftest test-pearl-filter-assignee-id () + ":assignee-id compiles to assignee.id.eq -- rename-proof, used by user favorites." + (should (equal (pearl--build-issue-filter '(:assignee-id "u-1")) + '(("assignee" ("id" ("eq" . "u-1"))))))) + +(ert-deftest test-pearl-filter-label-id () + ":label-id compiles to labels.some.id.in [id] -- rename-proof, used by label favorites." + (should (equal (pearl--build-issue-filter '(:label-id "lbl-1")) + '(("labels" ("some" ("id" ("in" . ["lbl-1"])))))))) + +(ert-deftest test-pearl-filter-validate-rejects-empty-assignee-id () + "An empty :assignee-id is a user-error." + (should-error (pearl--validate-issue-filter '(:assignee-id "")) :type 'user-error)) + +(ert-deftest test-pearl-filter-validate-rejects-empty-label-id () + "An empty :label-id is a user-error." + (should-error (pearl--validate-issue-filter '(:label-id "")) :type 'user-error)) + +(ert-deftest test-pearl-filter-validate-rejects-unknown-id-key () + "An unknown key is still rejected (sanity that :label-id/:assignee-id were registered)." + (should-error (pearl--validate-issue-filter '(:not-a-key "x")) :type 'user-error)) + (provide 'test-pearl-filter) ;;; test-pearl-filter.el ends here diff --git a/tests/test-pearl-keymap.el b/tests/test-pearl-keymap.el index b465395..5cc658d 100644 --- a/tests/test-pearl-keymap.el +++ b/tests/test-pearl-keymap.el @@ -66,7 +66,8 @@ The lowercase `c' is no longer a direct command -- it is the create prefix." (should (keymapp (lookup-key pearl-prefix-map (kbd "y"))))) ; copy (ert-deftest test-pearl-prefix-map-fetch-group () - "The fetch group resolves each source command." + "The fetch group resolves each source command, including the unified pick-source." + (should (eq 'pearl-pick-source (lookup-key pearl-prefix-map (kbd "f s")))) (should (eq 'pearl-list-issues (lookup-key pearl-prefix-map (kbd "f o")))) (should (eq 'pearl-list-issues-by-project (lookup-key pearl-prefix-map (kbd "f p")))) (should (eq 'pearl-list-issues-filtered (lookup-key pearl-prefix-map (kbd "f f")))) -- cgit v1.2.3