aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-03 00:11:25 -0500
committerCraig Jennings <c@cjennings.net>2026-06-03 00:11:25 -0500
commit8acc99d88527a9dfecf7ebceb858f344819d10c3 (patch)
tree025136d5f9dcce9641d28b19ec9113fb38e05a13
parent68d51b341bccc72c7b01f547e43ce90a76729244 (diff)
downloadpearl-8acc99d88527a9dfecf7ebceb858f344819d10c3.tar.gz
pearl-8acc99d88527a9dfecf7ebceb858f344819d10c3.zip
fix(views): rebuild Linear view URLs instead of querying the missing url field
Linear's CustomView type has no url field. Three GraphQL selections requested it (the custom-views list query and both the create and update mutation payloads), so each of those requests returned HTTP 400 and pearl got nil back. Running a Linear view came up with an empty picker, and publishing or updating a local view to Linear failed silently. The test suite stayed green because the view tests mocked a url field the real API never returns. I dropped url from all three selections. The workspace urlKey is now fetched once and cached, and a view's web URL is rebuilt as https://linear.app/<urlKey>/view/<view-id>, the shape Linear's own favorite URLs use (the full view UUID, not the slug id). pearl-open-current-view-in-linear keeps working, and when the urlKey is unavailable it degrades to nil so the link builder can't throw. That also stops a urlKey hiccup right after a successful publish from aborting the record step and orphaning the new view. I added regression locks asserting neither the list query nor the mutation payload selection requests url, since the field name is the schema contract that broke.
-rw-r--r--pearl.el86
-rw-r--r--tests/test-pearl-saved-query-sync.el7
-rw-r--r--tests/test-pearl-views.el49
3 files changed, 121 insertions, 21 deletions
diff --git a/pearl.el b/pearl.el
index 22ad714..d6f14de 100644
--- a/pearl.el
+++ b/pearl.el
@@ -426,6 +426,12 @@ Populated on first listing; clear it (or pass a force argument) to refresh.")
"Cached current-viewer plist (:id :name), fetched once per session.
Backs the comment-edit permission check; clear it to force a refresh.")
+(defvar pearl--cache-org-url-key nil
+ "Cached workspace URL key (organization.urlKey), fetched once per session.
+Backs `pearl--view-url', which reconstructs a Custom View's web link because
+Linear's `CustomView' type exposes no `url' field. Clear it to force a
+refresh.")
+
;; Progress tracking variables
(defvar pearl--active-requests 0
"Number of currently active API requests.")
@@ -583,7 +589,8 @@ semantics. A personal workspace's teams must never bleed into a work one."
pearl--cache-states nil
pearl--cache-team-collections nil
pearl--cache-views nil
- pearl--cache-viewer nil))
+ pearl--cache-viewer nil
+ pearl--cache-org-url-key nil))
(defun pearl--mode-line-lighter ()
"Return the `pearl-mode' mode-line lighter, naming the active account.
@@ -1629,17 +1636,28 @@ raw nodes (normalized at the render boundary), paged like the general fetch."
:message "Failed to fetch view issues"))))))))
(pearl--page-issues page-fn callback)))
-(defun pearl--custom-views (&optional force)
- "Return the workspace's Custom Views, fetching once and caching.
-Each node carries id/name/description/shared/url. A non-nil FORCE refetches."
- (or (and (not force) pearl--cache-views)
- (let* ((query "query CustomViews($first: Int!, $after: String) {
+(defconst pearl--linear-web-base "https://linear.app"
+ "Base URL of the Linear web app, used to build Custom View links.
+Distinct from `pearl-graphql-url' (the API endpoint): a view's web URL is
+\"<base>/<workspace-url-key>/view/<view-id>\".")
+
+(defun pearl--custom-views-query ()
+ "GraphQL query listing the workspace's Custom Views.
+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 url team { id } }
+ nodes { id name description shared team { id } }
pageInfo { hasNextPage endCursor }
}
}")
- (response (pearl--graphql-request query '(("first" . 100))))
+
+(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."
+ (or (and (not force) pearl--cache-views)
+ (let* ((response (pearl--graphql-request (pearl--custom-views-query)
+ '(("first" . 100))))
(views (and response
(pearl--node-list
(cdr (assoc 'customViews (cdr (assoc 'data response))))))))
@@ -1647,6 +1665,39 @@ Each node carries id/name/description/shared/url. A non-nil FORCE refetches."
(setq pearl--cache-views views))
views)))
+(defun pearl--org-url-key (&optional force)
+ "Return the workspace URL key (organization.urlKey), fetching once and caching.
+A non-nil FORCE refetches. Returns nil when the lookup fails, so callers that
+build links degrade rather than emit a broken URL."
+ (or (and (not force) pearl--cache-org-url-key)
+ ;; Best-effort: a missing key or transient failure yields nil so a link
+ ;; rebuild degrades rather than throwing -- notably, so a urlKey hiccup
+ ;; right after a successful publish can't abort the record step and
+ ;; orphan the freshly-created view.
+ (let* ((response (ignore-errors
+ (pearl--graphql-request "query { organization { urlKey } }")))
+ (url-key (cdr (assoc 'urlKey
+ (cdr (assoc 'organization
+ (cdr (assoc 'data response))))))))
+ (when (and url-key (not (string-empty-p url-key)))
+ (setq pearl--cache-org-url-key url-key)))))
+
+(defun pearl--view-url (view-id)
+ "Build the Linear web URL for the Custom View VIEW-ID, or nil when unknown.
+Linear's `CustomView' type exposes no `url' field, so the link is rebuilt from
+the workspace URL key and the view id -- the same shape Linear's own favorite
+URLs carry (the full view UUID, not the slug id). Returns nil when VIEW-ID is
+empty or the workspace URL key is unavailable, so callers degrade instead of
+emitting a malformed link."
+ (let ((url-key (pearl--org-url-key)))
+ (when (and view-id (not (string-empty-p view-id)) url-key)
+ (format "%s/%s/view/%s" pearl--linear-web-base url-key view-id))))
+
+(defconst pearl--customview-payload-fields "id name shared team { id key name }"
+ "Field selection for a `CustomView' returned by the create/update mutations.
+Selects no `url' -- the type has none, and requesting it 400s the mutation; the
+link is rebuilt by `pearl--view-url' from the returned id.")
+
(defun pearl--customview-create-async (name team-id shared filter-data callback)
"Create a Custom View on Linear.
NAME is the view name. TEAM-ID is the team UUID or nil for a personal/
@@ -1655,12 +1706,12 @@ workspace view. SHARED is t or nil. FILTER-DATA is a compiled
`CustomViewCreateInput.filterData' accepts the same shape the issues
query takes. CALLBACK is invoked with a plist
`(:success BOOL :view ALIST-OR-NIL :error STRING-OR-NIL)'."
- (let* ((query "mutation CustomViewCreate($input: CustomViewCreateInput!) {
+ (let* ((query (format "mutation CustomViewCreate($input: CustomViewCreateInput!) {
customViewCreate(input: $input) {
success
- customView { id name shared url team { id key name } }
+ customView { %s }
}
-}")
+}" pearl--customview-payload-fields))
(input (append (list (cons "name" name)
(cons "shared" (if shared t :json-false))
(cons "filterData" filter-data))
@@ -1712,12 +1763,12 @@ within Linear's standard window)."
UPDATE-INPUT is an alist of `CustomViewUpdateInput' fields -- any subset
of `name', `shared', `filterData', `teamId'. CALLBACK is invoked with a
plist `(:success BOOL :view ALIST-OR-NIL :error STRING-OR-NIL)'."
- (let* ((query "mutation CustomViewUpdate($id: String!, $input: CustomViewUpdateInput!) {
+ (let* ((query (format "mutation CustomViewUpdate($id: String!, $input: CustomViewUpdateInput!) {
customViewUpdate(id: $id, input: $input) {
success
- customView { id name shared url team { id key name } }
+ customView { %s }
}
-}")
+}" pearl--customview-payload-fields))
(variables `(("id" . ,view-id)
("input" . ,update-input)))
(success-fn
@@ -4840,7 +4891,7 @@ scope-and-visibility display string for the success message."
(success (plist-get result :success))
(view (and success (plist-get result :view)))
(view-id (and view (cdr (assoc 'id view))))
- (view-url (and view (cdr (assoc 'url view)))))
+ (view-url (and view-id (pearl--view-url view-id))))
(cond
((and success view-id)
(pearl--sync-record-or-orphan-error
@@ -4901,8 +4952,7 @@ the scope-and-visibility display string for the success message."
#'pearl--customview-update-async
view-id update-input))
(success (plist-get result :success))
- (view (and success (plist-get result :view)))
- (view-url (and view (cdr (assoc 'url view)))))
+ (view-url (and view-id (pearl--view-url view-id))))
(cond
(success
(pearl--sync-record-or-orphan-error
@@ -6216,7 +6266,7 @@ dirty-buffer guard) and records the view as the active source."
(unless view-id
(user-error "No Custom View named %s" view-name))
(let ((source (list :type 'view :name view-name :id view-id
- :url (cdr (assoc 'url view)))))
+ :url (pearl--view-url view-id))))
(pearl--progress "Running view %s..." view-name)
(pearl--query-view-async
view-id
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
index 534993e..91a7061 100644
--- a/tests/test-pearl-saved-query-sync.el
+++ b/tests/test-pearl-saved-query-sync.el
@@ -199,6 +199,12 @@ label, plist team-id nil + team-name \"Personal\" + shared nil."
;;; pearl--customview-create-async
+(ert-deftest test-pearl-customview-payload-fields-omit-url ()
+ "The mutation payload selection must not request the unsupported `url' field.
+Linear's `CustomView' has no `url'; selecting it 400s the create/update and
+silently fails the publish (the 2026-06-02 regression)."
+ (should-not (string-match-p "\\burl\\b" pearl--customview-payload-fields)))
+
(ert-deftest test-pearl-customview-create-async-success ()
"A successful `customViewCreate' invokes the callback with `:success' t and the view."
(testutil-linear-with-response
@@ -459,6 +465,7 @@ erased on every re-sync."
((symbol-function 'pearl--progress) (lambda (&rest _) nil))
((symbol-function 'message) (lambda (&rest _) nil))
((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat"))
((symbol-function 'pearl--customview-update-async)
(lambda (_view-id input cb)
(setq captured-input input)
diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el
index a750ae0..e22a6d3 100644
--- a/tests/test-pearl-views.el
+++ b/tests/test-pearl-views.el
@@ -55,13 +55,52 @@
(cl-incf calls)
(funcall (plist-get args :success) :data
'((data (customViews
- (nodes . [((id . "v1") (name . "My View") (url . "https://x"))])
+ (nodes . [((id . "v1") (name . "My View"))])
(pageInfo (hasNextPage . :json-false)))))))))
(let ((views (pearl--custom-views)))
(should (= 1 (length views)))
(pearl--custom-views)
(should (= 1 calls))))))
+(ert-deftest test-pearl-custom-views-query-omits-url ()
+ "The Custom Views list query must not select the unsupported `url' field.
+Linear's `CustomView' type has no `url'; requesting it 400s the whole query
+and leaves the run-view picker empty (the 2026-06-02 regression)."
+ (should-not (string-match-p "\\burl\\b" (pearl--custom-views-query))))
+
+;;; --org-url-key (cached) and --view-url (reconstructed)
+
+(ert-deftest test-pearl-org-url-key-caches ()
+ "The workspace url key is fetched once and served from cache."
+ (let ((pearl-api-key "test-key")
+ (pearl--cache-org-url-key nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (cl-incf calls)
+ (funcall (plist-get args :success) :data
+ '((data (organization (urlKey . "deepsat"))))))))
+ (should (string= "deepsat" (pearl--org-url-key)))
+ (pearl--org-url-key)
+ (should (= 1 calls)))))
+
+(ert-deftest test-pearl-view-url-builds-from-urlkey ()
+ "A view's web URL is reconstructed from the workspace url key and view id."
+ (cl-letf (((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat")))
+ (should (string= "https://linear.app/deepsat/view/abc123"
+ (pearl--view-url "abc123")))))
+
+(ert-deftest test-pearl-view-url-nil-when-no-urlkey ()
+ "With no workspace url key, the view URL is nil so callers degrade gracefully."
+ (cl-letf (((symbol-function 'pearl--org-url-key) (lambda (&optional _) nil)))
+ (should-not (pearl--view-url "abc123"))))
+
+(ert-deftest test-pearl-view-url-nil-when-empty-view-id ()
+ "A nil or empty view id yields no URL rather than a malformed link."
+ (cl-letf (((symbol-function 'pearl--org-url-key) (lambda (&optional _) "deepsat")))
+ (should-not (pearl--view-url nil))
+ (should-not (pearl--view-url ""))))
+
;;; run-view
(ert-deftest test-pearl-run-view-renders-with-view-source ()
@@ -69,7 +108,10 @@
(let ((ran-id nil) (rendered-source nil))
(cl-letf (((symbol-function 'pearl--custom-views)
(lambda (&optional _force)
- '(((id . "v1") (name . "My View") (url . "https://linear.app/view/v1")))))
+ ;; Nodes carry no `url' -- Linear's CustomView has none; the
+ ;; source URL is reconstructed from the workspace url key.
+ '(((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)
(funcall cb (pearl--make-query-result 'ok :issues nil))))
@@ -79,7 +121,8 @@
(should (string= "v1" ran-id))
(should (eq 'view (plist-get rendered-source :type)))
(should (string= "v1" (plist-get rendered-source :id)))
- (should (string= "https://linear.app/view/v1" (plist-get rendered-source :url))))))
+ (should (string= "https://linear.app/deepsat/view/v1"
+ (plist-get rendered-source :url))))))
;;; refresh-current-view, view branch