aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pearl.el66
-rw-r--r--tests/test-pearl-views.el86
2 files changed, 138 insertions, 14 deletions
diff --git a/pearl.el b/pearl.el
index d6f14de..ca9d0b1 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1581,13 +1581,41 @@ gone\" apart from \"the API call failed.\""
(funcall callback (or issue :missing)))))
(lambda (_error _response _data) (funcall callback :error))))
+(defconst pearl--default-issue-sort
+ (vector '(("updatedAt" ("order" . "Descending"))))
+ "Fallback issue sort for a Custom View whose configured ordering pearl can't
+reproduce: most-recently-updated first, matching the default `pearl-list-issues'
+uses. Keeps a view from falling through to Linear's createdAt-desc API default,
+which reads as random next to the rest of pearl.")
+
+(defconst pearl--view-sortable-orderings
+ '("priority" "estimate" "title" "label" "slaStatus" "createdAt" "updatedAt"
+ "completedAt" "dueDate" "accumulatedStateUpdatedAt" "cycle" "milestone"
+ "assignee" "project" "team" "manual" "workflowState")
+ "Linear `viewOrdering' values that also exist as `IssueSortInput' keys, so
+pearl can reproduce the view's sort server-side. An ordering outside this set
+falls back to `pearl--default-issue-sort'.")
+
+(defun pearl--view-sort-arg (ordering direction)
+ "Translate a Custom View's ORDERING and DIRECTION into an `IssueSortInput' list.
+ORDERING is Linear's `viewOrdering' string; DIRECTION is `viewOrderingDirection'
+(\"asc\", \"desc\", or nil). Returns a one-element vector usable as the `sort'
+query variable, or nil when ORDERING isn't one pearl can reproduce -- the caller
+then falls back to `pearl--default-issue-sort'. A nil DIRECTION defaults to
+Descending, the field's natural order and what Linear's UI shows when no
+direction is set."
+ (when (member ordering pearl--view-sortable-orderings)
+ (let ((order (if (equal direction "asc") "Ascending" "Descending")))
+ (vector (list (cons ordering (list (cons "order" order))))))))
+
(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."
- (format "query ViewIssues($id: String!, $first: Int!, $after: String) {
+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!]) {
customView(id: $id) {
- issues(first: $first, after: $after) {
+ issues(first: $first, after: $after, sort: $sort) {
nodes {
id identifier title description priority url updatedAt
state { id name type }
@@ -1603,16 +1631,21 @@ view-populated list renders them."
}
}" (pearl--list-comments-fragment)))
-(defun pearl--query-view-async (view-id callback)
+(defun pearl--query-view-async (view-id callback &optional sort)
"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."
- (let ((page-fn
+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."
+ (let* ((sort (or sort pearl--default-issue-sort))
+ (page-fn
(lambda (after page-cb)
(pearl--graphql-request-async
(pearl--view-issues-query)
`(("id" . ,view-id)
("first" . 100)
+ ("sort" . ,sort)
,@(when after (list (cons "after" after))))
(lambda (response)
(if (or (null response)
@@ -1647,14 +1680,17 @@ Selects no `url' -- Linear's `CustomView' type has none, and requesting it
400s the whole query (see `pearl--view-url' for how the link is rebuilt)."
"query CustomViews($first: Int!, $after: String) {
customViews(first: $first, after: $after) {
- nodes { id name description shared team { id } }
+ nodes { id name description shared team { id }
+ viewPreferencesValues { viewOrdering viewOrderingDirection } }
pageInfo { hasNextPage endCursor }
}
}")
(defun pearl--custom-views (&optional force)
"Return the workspace's Custom Views, fetching once and caching.
-Each node carries id/name/description/shared/team. A non-nil FORCE refetches."
+Each node carries id/name/description/shared/team plus viewPreferencesValues
+(the configured ordering, read by `pearl--view-sort-arg'). A non-nil FORCE
+refetches."
(or (and (not force) pearl--cache-views)
(let* ((response (pearl--graphql-request (pearl--custom-views-query)
'(("first" . 100))))
@@ -6246,7 +6282,8 @@ commands. Errors if no source is recorded."
merge))
('view
(pearl--progress "Refreshing %s..." (pearl--source-name source))
- (pearl--query-view-async (plist-get source :id) merge))
+ (pearl--query-view-async (plist-get source :id) merge
+ (plist-get source :sort)))
(_ (user-error "Unknown Linear source type: %s" (plist-get source :type)))))))
;;;###autoload
@@ -6265,12 +6302,17 @@ 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 ((source (list :type 'view :name view-name :id view-id
- :url (pearl--view-url view-id))))
+ (let* ((prefs (cdr (assoc 'viewPreferencesValues view)))
+ (sort (pearl--view-sort-arg (cdr (assoc 'viewOrdering prefs))
+ (cdr (assoc 'viewOrderingDirection prefs))))
+ (source (list :type 'view :name view-name :id view-id
+ :url (pearl--view-url view-id)
+ :sort sort)))
(pearl--progress "Running view %s..." view-name)
(pearl--query-view-async
view-id
- (lambda (result) (pearl--render-query-result result source))))))
+ (lambda (result) (pearl--render-query-result result source))
+ sort))))
;;;###autoload
(defun pearl-open-current-view-in-linear ()
diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el
index e22a6d3..7a211df 100644
--- a/tests/test-pearl-views.el
+++ b/tests/test-pearl-views.el
@@ -101,6 +101,51 @@ and leaves the run-view picker empty (the 2026-06-02 regression)."
(should-not (pearl--view-url nil))
(should-not (pearl--view-url ""))))
+;;; view sort — match the Linear view's configured ordering
+
+(ert-deftest test-pearl-view-sort-arg-maps-priority-desc ()
+ "A priority ordering with desc direction maps to an IssueSortInput list."
+ (should (equal (vector '(("priority" ("order" . "Descending"))))
+ (pearl--view-sort-arg "priority" "desc"))))
+
+(ert-deftest test-pearl-view-sort-arg-honors-asc-direction ()
+ "An asc direction maps to Ascending."
+ (should (equal (vector '(("accumulatedStateUpdatedAt" ("order" . "Ascending"))))
+ (pearl--view-sort-arg "accumulatedStateUpdatedAt" "asc"))))
+
+(ert-deftest test-pearl-view-sort-arg-defaults-nil-direction-to-descending ()
+ "A nil direction defaults to Descending — the field's natural order, and what
+Linear's UI shows for a priority view with no explicit direction."
+ (should (equal (vector '(("priority" ("order" . "Descending"))))
+ (pearl--view-sort-arg "priority" nil))))
+
+(ert-deftest test-pearl-view-sort-arg-unsupported-ordering-is-nil ()
+ "An ordering with no IssueSortInput key yields nil, so the caller falls back
+to the default sort rather than sending an invalid sort key."
+ (should-not (pearl--view-sort-arg "someExoticOrdering" "asc")))
+
+(ert-deftest test-pearl-view-issues-query-accepts-sort ()
+ "The view issues query declares the sort variable and forwards it to the
+issues connection, so a view runs under a chosen order."
+ (let ((q (pearl--view-issues-query)))
+ (should (string-match-p "\\$sort: \\[IssueSortInput!\\]" q))
+ (should (string-match-p "sort: \\$sort" q))))
+
+(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."
+ (let ((sent 'unset))
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q vars success &optional _err)
+ (setq sent (cdr (assoc "sort" vars)))
+ (funcall success
+ '((data (customView
+ (issues (nodes . [])
+ (pageInfo (hasNextPage . :json-false)
+ (endCursor))))))))))
+ (pearl--query-view-async "v1" #'ignore)
+ (should (equal pearl--default-issue-sort sent)))))
+
;;; run-view
(ert-deftest test-pearl-run-view-renders-with-view-source ()
@@ -113,7 +158,7 @@ and leaves the run-view picker empty (the 2026-06-02 regression)."
'(((id . "v1") (name . "My View")))))
((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat"))
((symbol-function 'pearl--query-view-async)
- (lambda (id cb) (setq ran-id id)
+ (lambda (id cb &optional _sort) (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))))
@@ -124,6 +169,26 @@ and leaves the run-view picker empty (the 2026-06-02 regression)."
(should (string= "https://linear.app/deepsat/view/v1"
(plist-get rendered-source :url))))))
+(ert-deftest test-pearl-run-view-applies-and-stamps-view-sort ()
+ "Running a view resolves its configured ordering, passes it to the query, and
+records it on the source so a later refresh reuses the same order."
+ (let ((passed-sort 'unset) (rendered-source nil))
+ (cl-letf (((symbol-function 'pearl--custom-views)
+ (lambda (&optional _force)
+ '(((id . "v1") (name . "My View")
+ (viewPreferencesValues (viewOrdering . "priority")
+ (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)
+ (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 (vector '(("priority" ("order" . "Descending")))) passed-sort))
+ (should (equal (vector '(("priority" ("order" . "Descending"))))
+ (plist-get rendered-source :sort))))))
+
;;; refresh-current-view, view branch
(ert-deftest test-pearl-refresh-current-view-runs-view-source ()
@@ -135,13 +200,30 @@ and leaves the run-view picker empty (the 2026-06-02 regression)."
(prin1-to-string source)))
(org-mode)
(cl-letf (((symbol-function 'pearl--query-view-async)
- (lambda (id cb) (setq view-ran id)
+ (lambda (id cb &optional _sort) (setq view-ran id)
(funcall cb (pearl--make-query-result 'ok :issues nil))))
((symbol-function 'pearl--merge-query-result)
(lambda (&rest _) nil)))
(pearl-refresh-current-view)
(should (string= "v1" view-ran))))))
+(ert-deftest test-pearl-refresh-current-view-passes-stored-sort ()
+ "Refreshing a view source forwards the :sort recorded on it, so the refreshed
+list keeps the order the view was run under."
+ (let ((passed-sort 'unset)
+ (source '(:type view :name "My View" :id "v1"
+ :sort [(("priority" ("order" . "Descending")))])))
+ (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) (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))))))
+
;;; open-current-view-in-linear
(ert-deftest test-pearl-open-current-view-visits-url ()