aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.org2
-rw-r--r--pearl.el131
-rw-r--r--tests/test-pearl-views.el161
3 files changed, 273 insertions, 21 deletions
diff --git a/README.org b/README.org
index c79ecc9..c98412c 100644
--- a/README.org
+++ b/README.org
@@ -278,6 +278,8 @@ Linear stores a view's filter as an =and=/=or= tree that's richer than Pearl's A
| =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.
+
Ad-hoc filtering starts with a team, then completes states, projects, and labels from that team's actual Linear values. The State and Labels prompts are multi-select (comma-separated): pick several states to match issues in any of them, e.g. =(:state ("Todo" "In Review"))=. The assignee prompt offers =me=, a specific =member= (resolved to a user id), or =any= (no scoping). Local views are local Lisp data:
#+begin_src emacs-lisp
diff --git a/pearl.el b/pearl.el
index a5fa205..1b51577 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1608,14 +1608,70 @@ direction is set."
(let ((order (if (equal direction "asc") "Ascending" "Descending")))
(vector (list (cons ordering (list (cons "order" order))))))))
+(defconst pearl--completed-state-types '("completed" "canceled" "duplicate")
+ "WorkflowState `type' values Linear treats as closed for a view's
+`showCompletedIssues' preference. Linear's UI groups Done under `completed'
+and both Canceled and Duplicate states under `canceled'/`duplicate', and the
+\"completed issues\" toggle hides all three. Excluding by closed type (rather
+than including by open type) fails toward showing a stray closed issue rather
+than hiding genuine open work if Linear ever adds a type.")
+
+(defconst pearl--view-completed-windows
+ '(("day" . 1) ("week" . 7) ("month" . 30) ("quarter" . 90) ("year" . 365))
+ "Linear `showCompletedIssues' recency keywords mapped to a day count.
+A view set to one of these shows open issues plus issues completed or canceled
+within the window. The month/quarter/year counts are fixed-day approximations
+of Linear's calendar windows -- close enough for a list that refreshes often,
+and erring toward showing slightly more rather than hiding recent work.")
+
+(defun pearl--view-open-issues-clause ()
+ "IssueFilter clause matching only open issues -- excludes the closed state
+types in `pearl--completed-state-types'."
+ (list (cons "state"
+ (list (cons "type"
+ (list (cons "nin"
+ (vconcat pearl--completed-state-types))))))))
+
+(defun pearl--view-completed-filter (show-completed &optional now)
+ "Translate a Custom View's SHOW-COMPLETED preference into an `IssueFilter'.
+SHOW-COMPLETED is Linear's `viewPreferencesValues.showCompletedIssues' string.
+NOW is the reference time for a recency window (defaults to `current-time');
+recomputing it on each run/refresh keeps a window relative to fetch time rather
+than to when the view was first opened.
+
+Returns an alist suitable as the `filter' GraphQL variable, AND-combined with
+the view's own `filterData' by the `customView.issues' connection, or nil when
+no extra filtering is needed:
+- \"all\", nil, or any value pearl doesn't recognize -> nil (show everything,
+ today's behavior; degrading an unknown value to \"all\" never hides open work).
+- \"none\" -> only open issues (see `pearl--view-open-issues-clause').
+- a recency window (\"day\"/\"week\"/\"month\"/\"quarter\"/\"year\") -> open issues
+ OR issues whose `completedAt' or `canceledAt' falls within the window."
+ (cond
+ ((member show-completed '(nil "all")) nil)
+ ((equal show-completed "none") (pearl--view-open-issues-clause))
+ ((assoc show-completed pearl--view-completed-windows)
+ (let* ((days (cdr (assoc show-completed pearl--view-completed-windows)))
+ (cutoff (time-subtract (or now (current-time)) (days-to-time days)))
+ (iso (format-time-string "%Y-%m-%dT%H:%M:%S.000Z" cutoff t)))
+ (list (cons "or"
+ (vector (pearl--view-open-issues-clause)
+ (list (cons "completedAt" (list (cons "gte" iso))))
+ (list (cons "canceledAt" (list (cons "gte" iso)))))))))
+ (t nil)))
+
(defun pearl--view-issues-query ()
"GraphQL query running a Custom View's own filter server-side, by view id.
Pulls each issue's recent comments (see `pearl--list-comments-fragment') so a
view-populated list renders them. The `sort' variable orders the issues; see
-`pearl--view-sort-arg' and `pearl--default-issue-sort'."
- (format "query ViewIssues($id: String!, $first: Int!, $after: String, $sort: [IssueSortInput!]) {
+`pearl--view-sort-arg' and `pearl--default-issue-sort'. The `filter' variable
+is AND-combined with the view's stored `filterData' -- pearl uses it to honor
+the view's `showCompletedIssues' preference, which lives outside `filterData'
+and so isn't applied by the connection alone (see
+`pearl--view-completed-filter')."
+ (format "query ViewIssues($id: String!, $first: Int!, $after: String, $sort: [IssueSortInput!], $filter: IssueFilter) {
customView(id: $id) {
- issues(first: $first, after: $after, sort: $sort) {
+ issues(first: $first, after: $after, sort: $sort, filter: $filter) {
nodes {
id identifier title description priority url updatedAt
state { id name type }
@@ -1631,13 +1687,16 @@ view-populated list renders them. The `sort' variable orders the issues; see
}
}" (pearl--list-comments-fragment)))
-(defun pearl--query-view-async (view-id callback &optional sort)
+(defun pearl--query-view-async (view-id callback &optional sort filter)
"Run the Custom View VIEW-ID server-side, calling CALLBACK with a query-result.
The view applies its own stored filter on Linear's side; issues come back as
raw nodes (normalized at the render boundary), paged like the general fetch.
SORT is an `IssueSortInput' list (the `sort' variable); when nil the view runs
under `pearl--default-issue-sort' so its order matches the rest of pearl rather
-than Linear's createdAt-desc API default."
+than Linear's createdAt-desc API default.
+FILTER is an optional `IssueFilter' AND-combined with the view's own filter (see
+`pearl--view-completed-filter'); when nil no `filter' variable is sent, so the
+view's `filterData' applies unchanged."
(let* ((sort (or sort pearl--default-issue-sort))
(page-fn
(lambda (after page-cb)
@@ -1646,6 +1705,7 @@ than Linear's createdAt-desc API default."
`(("id" . ,view-id)
("first" . 100)
("sort" . ,sort)
+ ,@(when filter (list (cons "filter" filter)))
,@(when after (list (cons "after" after))))
(lambda (response)
(if (or (null response)
@@ -1681,7 +1741,7 @@ Selects no `url' -- Linear's `CustomView' type has none, and requesting it
"query CustomViews($first: Int!, $after: String) {
customViews(first: $first, after: $after) {
nodes { id name description shared team { id }
- viewPreferencesValues { viewOrdering viewOrderingDirection } }
+ viewPreferencesValues { viewOrdering viewOrderingDirection showCompletedIssues } }
pageInfo { hasNextPage endCursor }
}
}")
@@ -1689,7 +1749,9 @@ Selects no `url' -- Linear's `CustomView' type has none, and requesting it
(defun pearl--custom-views (&optional force)
"Return the workspace's Custom Views, fetching once and caching.
Each node carries id/name/description/shared/team plus viewPreferencesValues
-(the configured ordering, read by `pearl--view-sort-arg'). A non-nil FORCE
+(the configured ordering, read by `pearl--view-sort-arg', and the
+showCompletedIssues preference, read by `pearl--view-completed-filter'). A
+non-nil FORCE
refetches."
(or (and (not force) pearl--cache-views)
(let* ((response (pearl--graphql-request (pearl--custom-views-query)
@@ -4256,10 +4318,7 @@ missing setup."
((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))))
+ (pearl--run-view-source source))
(t
(let ((filter (plist-get source :filter)))
(pearl--validate-issue-filter filter)
@@ -6283,9 +6342,45 @@ commands. Errors if no source is recorded."
('view
(pearl--progress "Refreshing %s..." (pearl--source-name source))
(pearl--query-view-async (plist-get source :id) merge
- (plist-get source :sort)))
+ (plist-get source :sort)
+ (pearl--view-completed-filter
+ (plist-get source :show-completed))))
(_ (user-error "Unknown Linear source type: %s" (plist-get source :type)))))))
+(defun pearl--view-node-prefs (view)
+ "Extract the runnable view preferences from a Custom View node VIEW.
+Returns `(:sort SORT :show-completed SC)': SORT is the `IssueSortInput' for the
+view's configured ordering (see `pearl--view-sort-arg', nil when pearl can't
+reproduce it), SC is the raw `showCompletedIssues' string (see
+`pearl--view-completed-filter'). A nil VIEW (e.g. a favorite referencing a view
+the list query didn't return) yields both nil, so the caller falls back to the
+house sort and shows all issues."
+ (let ((prefs (cdr (assoc 'viewPreferencesValues view))))
+ (list :sort (pearl--view-sort-arg (cdr (assoc 'viewOrdering prefs))
+ (cdr (assoc 'viewOrderingDirection prefs)))
+ :show-completed (cdr (assoc 'showCompletedIssues prefs)))))
+
+(defun pearl--run-view-source (source)
+ "Run a view SOURCE (`:type view', at least `:id'/`:name') and render it.
+Resolves the view's configured sort and `showCompletedIssues' from the cached
+Custom Views by id, stamps them onto SOURCE so a later refresh reuses them, and
+forwards both to the query. Used by `pearl-pick-source' for a favorited Linear
+view, whose favorite node carries no view preferences -- without this it would
+ignore the view's sort and show its completed/canceled issues."
+ (let* ((prefs (pearl--view-node-prefs
+ (seq-find (lambda (v) (equal (cdr (assoc 'id v))
+ (plist-get source :id)))
+ (pearl--custom-views))))
+ (sort (plist-get prefs :sort))
+ (show-completed (plist-get prefs :show-completed))
+ (source (append source (list :sort sort :show-completed show-completed))))
+ (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))
+ sort
+ (pearl--view-completed-filter show-completed))))
+
;;;###autoload
(defun pearl-run-linear-view (view-name)
"Run a Linear Custom View by VIEW-NAME and render it into the active file.
@@ -6302,17 +6397,19 @@ dirty-buffer guard) and records the view as the active source."
(view-id (and view (cdr (assoc 'id view)))))
(unless view-id
(user-error "No Custom View named %s" view-name))
- (let* ((prefs (cdr (assoc 'viewPreferencesValues view)))
- (sort (pearl--view-sort-arg (cdr (assoc 'viewOrdering prefs))
- (cdr (assoc 'viewOrderingDirection prefs))))
+ (let* ((prefs (pearl--view-node-prefs view))
+ (sort (plist-get prefs :sort))
+ (show-completed (plist-get prefs :show-completed))
(source (list :type 'view :name view-name :id view-id
:url (pearl--view-url view-id)
- :sort sort)))
+ :sort sort
+ :show-completed show-completed)))
(pearl--progress "Running view %s..." view-name)
(pearl--query-view-async
view-id
(lambda (result) (pearl--render-query-result result source))
- sort))))
+ sort
+ (pearl--view-completed-filter show-completed)))))
;;;###autoload
(defun pearl-open-current-view-in-linear ()
diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el
index 7a211df..3675200 100644
--- a/tests/test-pearl-views.el
+++ b/tests/test-pearl-views.el
@@ -131,6 +131,75 @@ issues connection, so a view runs under a chosen order."
(should (string-match-p "\\$sort: \\[IssueSortInput!\\]" q))
(should (string-match-p "sort: \\$sort" q))))
+;;; --view-completed-filter (showCompletedIssues -> IssueFilter)
+
+(ert-deftest test-pearl-view-completed-filter-all-is-nil ()
+ "showCompletedIssues \"all\" means show everything, so no extra filter is
+added — today's behavior, and the open clause never hides anything."
+ (should-not (pearl--view-completed-filter "all")))
+
+(ert-deftest test-pearl-view-completed-filter-nil-is-nil ()
+ "A view with no recorded preference adds no filter."
+ (should-not (pearl--view-completed-filter nil)))
+
+(ert-deftest test-pearl-view-completed-filter-unknown-is-nil ()
+ "An unrecognized preference value adds no filter rather than risk hiding open
+work — pearl degrades to showing everything, the same as \"all\"."
+ (should-not (pearl--view-completed-filter "someFutureValue")))
+
+(ert-deftest test-pearl-view-completed-filter-none-excludes-closed-types ()
+ "showCompletedIssues \"none\" restricts to open issues by excluding the closed
+state types (completed, canceled, duplicate) that Linear's UI hides."
+ (should (equal '(("state" ("type" ("nin" . ["completed" "canceled" "duplicate"]))))
+ (pearl--view-completed-filter "none"))))
+
+(ert-deftest test-pearl-view-completed-filter-window-ors-open-and-recent ()
+ "A recency window (e.g. \"week\") shows open issues OR issues completed or
+canceled within the window, mirroring Linear's \"completed in the past week\"."
+ (let* ((now (date-to-time "2026-06-08T12:00:00Z"))
+ (f (pearl--view-completed-filter "week" now)))
+ (should (equal
+ '(("or" . [(("state" ("type" ("nin" . ["completed" "canceled" "duplicate"]))))
+ (("completedAt" ("gte" . "2026-06-01T12:00:00.000Z")))
+ (("canceledAt" ("gte" . "2026-06-01T12:00:00.000Z")))]))
+ f))))
+
+(ert-deftest test-pearl-view-issues-query-accepts-filter ()
+ "The view issues query declares the filter variable and forwards it to the
+issues connection, so a view can drop completed issues server-side."
+ (let ((q (pearl--view-issues-query)))
+ (should (string-match-p "\\$filter: IssueFilter" q))
+ (should (string-match-p "filter: \\$filter" q))))
+
+(ert-deftest test-pearl-query-view-async-forwards-filter ()
+ "A non-nil filter is sent as the `filter' GraphQL variable."
+ (let ((sent 'unset))
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q vars success &optional _err)
+ (setq sent (cdr (assoc "filter" vars)))
+ (funcall success
+ '((data (customView
+ (issues (nodes . [])
+ (pageInfo (hasNextPage . :json-false)
+ (endCursor))))))))))
+ (pearl--query-view-async "v1" #'ignore nil '(("state" "x")))
+ (should (equal '(("state" "x")) sent)))))
+
+(ert-deftest test-pearl-query-view-async-omits-nil-filter ()
+ "With no filter, no `filter' variable is sent (the connection sees no filter,
+matching a view whose preference shows all issues)."
+ (let ((had-key 'unset))
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q vars success &optional _err)
+ (setq had-key (and (assoc "filter" vars) t))
+ (funcall success
+ '((data (customView
+ (issues (nodes . [])
+ (pageInfo (hasNextPage . :json-false)
+ (endCursor))))))))))
+ (pearl--query-view-async "v1" #'ignore nil nil)
+ (should-not had-key))))
+
(ert-deftest test-pearl-query-view-async-defaults-sort-to-updated-desc ()
"With no explicit sort, a view runs under updatedAt-desc rather than Linear's
createdAt-desc API default, so its order matches the rest of pearl."
@@ -158,7 +227,7 @@ createdAt-desc API default, so its order matches the rest of pearl."
'(((id . "v1") (name . "My View")))))
((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat"))
((symbol-function 'pearl--query-view-async)
- (lambda (id cb &optional _sort) (setq ran-id id)
+ (lambda (id cb &optional _sort _filter) (setq ran-id id)
(funcall cb (pearl--make-query-result 'ok :issues nil))))
((symbol-function 'pearl--render-query-result)
(lambda (_result source) (setq rendered-source source))))
@@ -180,7 +249,7 @@ records it on the source so a later refresh reuses the same order."
(viewOrderingDirection . "desc"))))))
((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat"))
((symbol-function 'pearl--query-view-async)
- (lambda (_id cb &optional sort) (setq passed-sort sort)
+ (lambda (_id cb &optional sort _filter) (setq passed-sort sort)
(funcall cb (pearl--make-query-result 'ok :issues nil))))
((symbol-function 'pearl--render-query-result)
(lambda (_result source) (setq rendered-source source))))
@@ -189,6 +258,73 @@ records it on the source so a later refresh reuses the same order."
(should (equal (vector '(("priority" ("order" . "Descending"))))
(plist-get rendered-source :sort))))))
+(ert-deftest test-pearl-run-view-applies-and-stamps-show-completed ()
+ "Running a view reads its showCompletedIssues preference, passes the matching
+filter to the query, and records the raw preference on the source so a later
+refresh rebuilds the window relative to the refresh time."
+ (let ((passed-filter 'unset) (rendered-source nil))
+ (cl-letf (((symbol-function 'pearl--custom-views)
+ (lambda (&optional _force)
+ '(((id . "v1") (name . "My View")
+ (viewPreferencesValues (showCompletedIssues . "none"))))))
+ ((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat"))
+ ((symbol-function 'pearl--query-view-async)
+ (lambda (_id cb &optional _sort filter) (setq passed-filter filter)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--render-query-result)
+ (lambda (_result source) (setq rendered-source source))))
+ (pearl-run-linear-view "My View")
+ (should (equal '(("state" ("type" ("nin" . ["completed" "canceled" "duplicate"]))))
+ passed-filter))
+ (should (string= "none" (plist-get rendered-source :show-completed))))))
+
+;;; --view-node-prefs and --run-view-source (favorited view dispatch)
+
+(ert-deftest test-pearl-view-node-prefs-extracts-sort-and-show-completed ()
+ "A Custom View node's preferences resolve to its IssueSortInput sort and raw
+showCompletedIssues string."
+ (let ((prefs (pearl--view-node-prefs
+ '((id . "v1")
+ (viewPreferencesValues (viewOrdering . "priority")
+ (viewOrderingDirection . "desc")
+ (showCompletedIssues . "none"))))))
+ (should (equal (vector '(("priority" ("order" . "Descending"))))
+ (plist-get prefs :sort)))
+ (should (string= "none" (plist-get prefs :show-completed)))))
+
+(ert-deftest test-pearl-view-node-prefs-nil-node-is-all-nil ()
+ "A nil node (a favorite referencing a view the list query didn't return) yields
+nil sort and nil show-completed, so the caller falls back to house defaults."
+ (let ((prefs (pearl--view-node-prefs nil)))
+ (should-not (plist-get prefs :sort))
+ (should-not (plist-get prefs :show-completed))))
+
+(ert-deftest test-pearl-run-view-source-resolves-prefs-by-id-and-stamps ()
+ "A favorited view source (no preferences of its own) resolves the view's sort
+and showCompletedIssues from the cached Custom Views, forwards both to the query,
+and stamps them on the source so a refresh reuses them — closing the gap where
+the pick-source path showed a view's completed/canceled issues and ignored sort."
+ (let ((passed-sort 'unset) (passed-filter 'unset) (rendered-source nil))
+ (cl-letf (((symbol-function 'pearl--custom-views)
+ (lambda (&optional _force)
+ '(((id . "v1") (name . "My View")
+ (viewPreferencesValues (viewOrdering . "priority")
+ (viewOrderingDirection . "desc")
+ (showCompletedIssues . "none"))))))
+ ((symbol-function 'pearl--query-view-async)
+ (lambda (_id cb &optional sort filter)
+ (setq passed-sort sort passed-filter filter)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--render-query-result)
+ (lambda (_result source) (setq rendered-source source))))
+ (pearl--run-view-source '(:type view :name "My View" :id "v1" :url "https://x"))
+ (should (equal (vector '(("priority" ("order" . "Descending")))) passed-sort))
+ (should (equal '(("state" ("type" ("nin" . ["completed" "canceled" "duplicate"]))))
+ passed-filter))
+ (should (string= "none" (plist-get rendered-source :show-completed)))
+ ;; The favorite's own keys survive the enrichment.
+ (should (string= "https://x" (plist-get rendered-source :url))))))
+
;;; refresh-current-view, view branch
(ert-deftest test-pearl-refresh-current-view-runs-view-source ()
@@ -200,7 +336,7 @@ records it on the source so a later refresh reuses the same order."
(prin1-to-string source)))
(org-mode)
(cl-letf (((symbol-function 'pearl--query-view-async)
- (lambda (id cb &optional _sort) (setq view-ran id)
+ (lambda (id cb &optional _sort _filter) (setq view-ran id)
(funcall cb (pearl--make-query-result 'ok :issues nil))))
((symbol-function 'pearl--merge-query-result)
(lambda (&rest _) nil)))
@@ -217,13 +353,30 @@ list keeps the order the view was run under."
(insert (format "#+LINEAR-SOURCE: %s\n\n" (prin1-to-string source)))
(org-mode)
(cl-letf (((symbol-function 'pearl--query-view-async)
- (lambda (_id cb &optional sort) (setq passed-sort sort)
+ (lambda (_id cb &optional sort _filter) (setq passed-sort sort)
(funcall cb (pearl--make-query-result 'ok :issues nil))))
((symbol-function 'pearl--merge-query-result)
(lambda (&rest _) nil)))
(pearl-refresh-current-view)
(should (equal [(("priority" ("order" . "Descending")))] passed-sort))))))
+(ert-deftest test-pearl-refresh-current-view-rebuilds-show-completed-filter ()
+ "Refreshing a view source rebuilds the completed-issues filter from the raw
+`:show-completed' it recorded, so an open-only view stays open-only on refresh."
+ (let ((passed-filter 'unset)
+ (source '(:type view :name "My View" :id "v1" :show-completed "none")))
+ (with-temp-buffer
+ (insert (format "#+LINEAR-SOURCE: %s\n\n" (prin1-to-string source)))
+ (org-mode)
+ (cl-letf (((symbol-function 'pearl--query-view-async)
+ (lambda (_id cb &optional _sort filter) (setq passed-filter filter)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--merge-query-result)
+ (lambda (&rest _) nil)))
+ (pearl-refresh-current-view)
+ (should (equal '(("state" ("type" ("nin" . ["completed" "canceled" "duplicate"]))))
+ passed-filter))))))
+
;;; open-current-view-in-linear
(ert-deftest test-pearl-open-current-view-visits-url ()