From 580309bb65a5e529c7aab5e58d3983a37c471023 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 3 Jun 2026 07:52:30 -0500 Subject: 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. --- tests/test-pearl-views.el | 161 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 157 insertions(+), 4 deletions(-) (limited to 'tests') 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 () -- cgit v1.2.3