diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-27 22:54:43 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-27 22:54:43 -0500 |
| commit | dcfecf3e4f46c920eb77ca9b3169d0719fa0b9fd (patch) | |
| tree | dc710dc4412152897aa7fa617c0cabc6bbc08caa /pearl.el | |
| parent | 15ce896744a8095c221bdeaf887f6311f9a94526 (diff) | |
| download | pearl-dcfecf3e4f46c920eb77ca9b3169d0719fa0b9fd.tar.gz pearl-dcfecf3e4f46c920eb77ca9b3169d0719fa0b9fd.zip | |
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`.
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 298 |
1 files changed, 271 insertions, 27 deletions
@@ -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)) |
