diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-03 07:52:30 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-03 07:52:30 -0500 |
| commit | 580309bb65a5e529c7aab5e58d3983a37c471023 (patch) | |
| tree | 10ebe78cbd8909a39ba126c77a560bc96174817a | |
| parent | 6ef196e306320804b80a502dab29dbafd12285bc (diff) | |
| download | pearl-580309bb65a5e529c7aab5e58d3983a37c471023.tar.gz pearl-580309bb65a5e529c7aab5e58d3983a37c471023.zip | |
fix(views): honor a Linear view's completed-issues display preference
A Linear view that hides completed issues still showed done and cancelled issues when run in pearl. The open-only restriction isn't in the view's filterData. It's a separate display preference, viewPreferencesValues.showCompletedIssues, which pearl fetched for sort but never applied. So the customView.issues connection returned every state type, and a "by status" or "open issues" view didn't look like itself.
I now read showCompletedIssues alongside the sort fields and translate it to an IssueFilter passed to the connection, AND-combined with the view's own filter. "all" adds nothing, "none" excludes the closed state types (completed, canceled, duplicate), and a recency window (day/week/month/quarter/year) keeps open issues plus those completed or canceled within the window. I stamp the raw preference on the source so a refresh rebuilds the window relative to the refresh time rather than first-run time.
Both entry points route through a shared pearl--view-node-prefs resolver, so pearl-run-linear-view and a favorited view picked in pearl-pick-source behave the same. That also closes a latent gap where the pick-source path ignored the view's configured sort.
Verified live against the workspace: a "none" view returns only open issues, and a "week" view returns open issues plus those closed within seven days, with none older leaking through.
| -rw-r--r-- | README.org | 2 | ||||
| -rw-r--r-- | pearl.el | 131 | ||||
| -rw-r--r-- | tests/test-pearl-views.el | 161 |
3 files changed, 273 insertions, 21 deletions
@@ -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 @@ -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 () |
