From 8acc99d88527a9dfecf7ebceb858f344819d10c3 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 3 Jun 2026 00:11:25 -0500 Subject: 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//view/, 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. --- pearl.el | 86 ++++++++++++++++++++++++++++-------- tests/test-pearl-saved-query-sync.el | 7 +++ tests/test-pearl-views.el | 49 ++++++++++++++++++-- 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 +\"//view/\".") + +(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 -- cgit v1.2.3