aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el298
1 files changed, 271 insertions, 27 deletions
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))