aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 09:28:53 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 09:28:53 -0500
commitfd94237903c15883173e438a5af4ba4e6bfdbd46 (patch)
tree05bd91e8c66f0332c8acb174aa64257e262367ac
parentc8b9ad1b72c60f87c15e942f17f7ff1066a41cb2 (diff)
downloadpearl-fd94237903c15883173e438a5af4ba4e6bfdbd46.tar.gz
pearl-fd94237903c15883173e438a5af4ba4e6bfdbd46.zip
feat(view-sync): pearl-pick-source distinguishes [saved] / [saved → scope] / [view]
This is Phase 4 of docs/saved-query-sync-spec.org (§ Extended pearl-pick-source candidate label). The picker now reads where each saved query lives at a glance: - `[saved] Name`: local-only saved query, dispatches as :type filter (unchanged) - `[saved → Engineering] Name`: synced to a team-scoped Linear view - `[saved → Personal] Name`: synced as a personal Linear view - `[saved → ?] Name`: synced, but the team id no longer resolves (deleted/renamed on Linear) or the teams cache wasn't loaded - `[view] Name`: Linear-authored view favorite (unchanged) A synced entry's source plist gets :type view, so the existing view-dispatch branch in pearl-pick-source routes through pearl--query-view-async against the linked Linear view. Refresh and pagination honor whatever the view's filter is on Linear now, not the local authoring filter pearl built it from. I added pearl--saved-query-scope-label to resolve the scope string from (spec, teams) and extended pearl--pick-source-candidates with an optional teams argument. pearl-pick-source fetches pearl--all-teams lazily, only when at least one saved query carries :linear-view-id, and feeds the result in. An all-local configuration pays no extra network cost. While in the picker code I also closed a latent gap the review surfaced: pearl-open-current-view-in-linear was about to break on synced-saved-query sources because the source plist had no :url. I extended pearl--customview-create-async and -update-async to fetch `url` in the response, pearl--save-query-mark-synced to take an optional VIEW-URL arg and store it as :linear-view-url, and the picker source plist to surface :url when the entry has one. Entries synced before this commit have no stored URL. A re-sync repopulates it. Mark-synced preserves a prior URL when VIEW-URL is omitted, so a re-sync whose API response somehow lacks the URL doesn't erase the working one. 15 new tests across test-pearl-favorites.el (the picker labels for team-scope, Personal, unknown-team, no-teams-arg, URL surfaced + URL absent, mixed local/synced) and test-pearl-saved-query-sync.el (mark-synced stores URL when given, preserves prior URL on re-sync omission). The existing 4 pick-source tests stay green. They pass either no teams arg or nil, so the scope-resolution branch is dead in their cases. 653 ert tests total green. make compile and make lint clean. Lower-severity findings from the review filed as follow-ups: the `?` label conflates "unknown team" and "no teams data" (could be split). pearl--all-teams cache has no TTL so stale team names persist across renames. The lazy teams fetch reuses the "Fetching favorites..." progress message during its own roundtrip. Synced entries silently drop the local :sort/:order because Linear's view doesn't carry them in v1.
-rw-r--r--pearl.el137
-rw-r--r--tests/test-pearl-favorites.el123
-rw-r--r--tests/test-pearl-saved-query-sync.el28
3 files changed, 256 insertions, 32 deletions
diff --git a/pearl.el b/pearl.el
index cb65ab3..724664b 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1158,7 +1158,7 @@ query takes. CALLBACK is invoked with a plist
(let* ((query "mutation CustomViewCreate($input: CustomViewCreateInput!) {
customViewCreate(input: $input) {
success
- customView { id name shared team { id key name } }
+ customView { id name shared url team { id key name } }
}
}")
(input (append (list (cons "name" name)
@@ -1215,7 +1215,7 @@ plist `(:success BOOL :view ALIST-OR-NIL :error STRING-OR-NIL)'."
(let* ((query "mutation CustomViewUpdate($id: String!, $input: CustomViewUpdateInput!) {
customViewUpdate(id: $id, input: $input) {
success
- customView { id name shared team { id key name } }
+ customView { id name shared url team { id key name } }
}
}")
(variables `(("id" . ,view-id)
@@ -3405,13 +3405,50 @@ active file with the query recorded as the source."
;;; pearl-pick-source -- the unified source picker (favorites + saved queries)
-(defun pearl--pick-source-candidates (favorites saved-queries)
+(defun pearl--saved-query-scope-label (spec teams)
+ "Return the scope display label for SPEC (a saved-query plist).
+Returns nil for a local-only entry (no `:linear-view-id'), \"Personal\"
+for a synced entry with no team scope, the team name when SPEC's
+`:linear-view-team-id' resolves against TEAMS, or \"?\" when the team
+id is set but missing from TEAMS (which surfaces a stale link to the
+user rather than silently mis-labelling)."
+ (let ((view-id (plist-get spec :linear-view-id))
+ (team-id (plist-get spec :linear-view-team-id)))
+ (when view-id
+ (cond ((null team-id) "Personal")
+ ((null teams) "?")
+ (t (or (cdr (assoc 'name
+ (seq-find
+ (lambda (tm)
+ (equal team-id (cdr (assoc 'id tm))))
+ teams)))
+ "?"))))))
+
+(defun pearl--pick-source-candidates (favorites saved-queries &optional teams)
"Build the picker's candidate alist `((DISPLAY . SOURCE-OR-ACTION) ...)'.
FAVORITES is a list of normalized favorite plists; SAVED-QUERIES is the
-`pearl-saved-queries' alist. Favorites come first in ascending `:sort-order';
-saved queries follow alphabetically. Each DISPLAY is `[KIND] TITLE' and is
-made unique by appending ` (#N)' on a true collision -- dispatch reads
-SOURCE-OR-ACTION, not the display string."
+`pearl-saved-queries' alist; TEAMS is the team alist list (see
+`pearl--all-teams') used to resolve the scope name of any saved-query
+entry carrying `:linear-view-id'. Favorites come first in ascending
+`:sort-order'; saved queries follow alphabetically.
+
+Each DISPLAY is one of:
+ `[KIND] TITLE' for favorites
+ `[saved] NAME' for a local-only saved query
+ `[saved \\u2192 SCOPE] NAME' for a synced saved query
+where SCOPE is the team name resolved from `:linear-view-team-id'
+against TEAMS, or \"Personal\" when there's no team scope, or \"?\" when
+the team id is stale (no longer in TEAMS) or TEAMS is nil.
+
+A synced entry's source plist is `(:type view :name NAME :id LINEAR-VIEW-ID)'
+so dispatch flows through the existing view branch and runs
+`pearl--query-view-async' against the synced view -- the synced query
+lives on Linear now and any filter drift there is honored at refresh.
+Local-only entries keep the `:type filter' shape and dispatch through
+`pearl--query-issues-async' against the stored authoring filter.
+
+Display strings are made unique by appending ` (#N)' on a true collision
+-- dispatch reads SOURCE-OR-ACTION, not the display string."
(let ((fav-sorted (sort (copy-sequence favorites)
(lambda (a b)
(< (or (plist-get a :sort-order) 0.0)
@@ -3438,11 +3475,23 @@ SOURCE-OR-ACTION, not the display string."
(dolist (sq saved-sorted)
(let* ((name (car sq))
(spec (cdr sq))
- (display (format "[saved] %s" name))
- (source (list :type 'filter :name name
- :filter (plist-get spec :filter)
- :sort (plist-get spec :sort)
- :order (plist-get spec :order))))
+ (view-id (plist-get spec :linear-view-id))
+ (scope-label (pearl--saved-query-scope-label spec teams))
+ (display (if view-id
+ (format "[saved → %s] %s" scope-label name)
+ (format "[saved] %s" name)))
+ (source (if view-id
+ ;; Include :url when stored so `pearl-open-current-
+ ;; view-in-linear' works on synced sources. Entries
+ ;; synced before :linear-view-url tracking landed
+ ;; carry no URL; a re-sync repopulates it.
+ (append (list :type 'view :name name :id view-id)
+ (let ((u (plist-get spec :linear-view-url)))
+ (when u (list :url u))))
+ (list :type 'filter :name name
+ :filter (plist-get spec :filter)
+ :sort (plist-get spec :sort)
+ :order (plist-get spec :order)))))
(push-unique display source))))
(nreverse alist)))
@@ -3473,7 +3522,14 @@ missing setup."
(let ((saved pearl-saved-queries))
(pearl--favorites-async
(lambda (favorites)
- (let ((cands (pearl--pick-source-candidates favorites saved)))
+ ;; Only fetch teams when at least one saved query is synced -- scope
+ ;; resolution is the only thing teams data feeds, and an all-local
+ ;; configuration has no scopes to resolve.
+ (let* ((teams (and (cl-some (lambda (sq)
+ (plist-get (cdr sq) :linear-view-id))
+ saved)
+ (pearl--all-teams)))
+ (cands (pearl--pick-source-candidates favorites saved teams)))
(unless cands
(user-error
"No favorites or saved queries -- star views in Linear, or set `pearl-saved-queries'"))
@@ -3747,14 +3803,20 @@ Returns the matching view alist or nil."
(t (or (eq view-shared :json-false) (null view-shared)))))))
views)))
-(defun pearl--save-query-mark-synced (name team-id shared view-id)
+(defun pearl--save-query-mark-synced (name team-id shared view-id &optional view-url)
"Persist the sync metadata for saved query NAME.
TEAM-ID is the team UUID or nil (personal scope). SHARED is t or nil.
-VIEW-ID is the Linear custom view UUID returned by the API. Adds or
-overwrites `:linear-view-id', `:linear-view-team-id', `:linear-view-shared',
-`:linear-view-synced-at' on the entry, then persists `pearl-saved-queries'
-via Customize. Signals `user-error' when NAME isn't in
-`pearl-saved-queries'."
+VIEW-ID is the Linear custom view UUID returned by the API. VIEW-URL,
+when provided, is the view's Linear web URL so
+`pearl-open-current-view-in-linear' can open the rendered buffer's
+source view in the browser; omit on a re-sync where the URL is
+unavailable to leave the prior `:linear-view-url' untouched.
+
+Adds or overwrites `:linear-view-id', `:linear-view-team-id',
+`:linear-view-shared', `:linear-view-synced-at', and (when VIEW-URL is
+non-nil) `:linear-view-url' on the entry, then persists
+`pearl-saved-queries' via Customize. Signals `user-error' when NAME
+isn't in `pearl-saved-queries'."
(let ((entry (assoc name pearl-saved-queries)))
(unless entry
(user-error "No saved query named %s" name))
@@ -3762,6 +3824,8 @@ via Customize. Signals `user-error' when NAME isn't in
(filter (plist-get spec :filter))
(sort (plist-get spec :sort))
(order (plist-get spec :order))
+ (existing-url (plist-get spec :linear-view-url))
+ (effective-url (or view-url existing-url))
(synced-at (format-time-string "%Y-%m-%dT%H:%M:%SZ" nil t))
(new-spec (append (list :filter filter)
(when sort (list :sort sort))
@@ -3769,26 +3833,32 @@ via Customize. Signals `user-error' when NAME isn't in
(list :linear-view-id view-id
:linear-view-team-id team-id
:linear-view-shared shared
- :linear-view-synced-at synced-at))))
+ :linear-view-synced-at synced-at)
+ (when effective-url
+ (list :linear-view-url effective-url)))))
(setq pearl-saved-queries
(cons (cons name new-spec)
(assoc-delete-all name (copy-sequence pearl-saved-queries))))
(customize-save-variable 'pearl-saved-queries pearl-saved-queries))))
(defun pearl--sync-record-or-orphan-error (name team-id shared view-id verb
- &optional scope-label)
+ &optional scope-label view-url)
"Persist sync metadata for NAME, or report an orphan view on persist failure.
TEAM-ID, SHARED, VIEW-ID match the just-completed sync (see
`pearl--save-query-mark-synced'). VERB is the past-tense action word
shown in the success message (\"Synced\" for create, \"Updated\" for
-update); SCOPE-LABEL is the optional scope-and-visibility display string
-appended in parens. Returns t on success, nil on failure. On failure
-the message names VIEW-ID explicitly so the user can find the orphan in
-Linear or re-sync with Replace to reconcile -- the API call already
-succeeded; only the local link couldn't be saved."
+update). SCOPE-LABEL is the optional scope-and-visibility display
+string appended in brackets. VIEW-URL, when provided, is the view's
+Linear web URL stored alongside the rest of the sync metadata so
+`pearl-open-current-view-in-linear' can dispatch to it.
+
+Returns t on success, nil on failure. On failure the message names
+VIEW-ID explicitly so the user can find the orphan in Linear or re-sync
+with Replace to reconcile -- the API call already succeeded; only the
+local link couldn't be saved."
(condition-case err
(progn
- (pearl--save-query-mark-synced name team-id shared view-id)
+ (pearl--save-query-mark-synced name team-id shared view-id view-url)
(message "%s %s to Linear (view id %s)%s"
verb name view-id
(if scope-label (format " [%s]" scope-label) ""))
@@ -3955,12 +4025,13 @@ scope-and-visibility display string for the success message."
#'pearl--customview-create-async
target-name team-id shared filter-data))
(success (plist-get result :success))
- (view-id (and success
- (cdr (assoc 'id (plist-get result :view))))))
+ (view (and success (plist-get result :view)))
+ (view-id (and view (cdr (assoc 'id view))))
+ (view-url (and view (cdr (assoc 'url view)))))
(cond
((and success view-id)
(pearl--sync-record-or-orphan-error
- name team-id shared view-id "Synced" scope-label))
+ name team-id shared view-id "Synced" scope-label view-url))
((plist-get result :timeout)
(message
"Sync of %s timed out after %ds; the create may still complete on Linear -- re-run and pick Replace to reconcile if a duplicate appears."
@@ -3982,11 +4053,13 @@ the scope-and-visibility display string for the success message."
(result (pearl--sync-saved-query-await
#'pearl--customview-update-async
view-id update-input))
- (success (plist-get result :success)))
+ (success (plist-get result :success))
+ (view (and success (plist-get result :view)))
+ (view-url (and view (cdr (assoc 'url view)))))
(cond
(success
(pearl--sync-record-or-orphan-error
- name team-id shared view-id "Updated" scope-label))
+ name team-id shared view-id "Updated" scope-label view-url))
((plist-get result :timeout)
(message
"Update of Linear view for %s timed out after %ds; the update may still apply -- refresh in Linear to confirm."
diff --git a/tests/test-pearl-favorites.el b/tests/test-pearl-favorites.el
index b47cde6..70507d7 100644
--- a/tests/test-pearl-favorites.el
+++ b/tests/test-pearl-favorites.el
@@ -213,6 +213,129 @@ For an issue node, IDENTIFIER fills the issue's user-facing key."
"No favorites and no saved queries yields no candidates."
(should (null (pearl--pick-source-candidates nil nil))))
+;;; pick-source candidate builder -- synced saved queries (view-sync Phase 4)
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-team-scope ()
+ "A team-scoped synced entry renders as `[saved \\u2192 <TeamName>] <Name>'."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (saved '(("My team view" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "team-eng-id"
+ :linear-view-shared t)))
+ (cands (pearl--pick-source-candidates nil saved teams)))
+ (should (assoc "[saved → Engineering] My team view" cands))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-personal-scope ()
+ "A personal synced entry renders as `[saved \\u2192 Personal] <Name>'."
+ (let* ((saved '(("Mine" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false)))
+ (cands (pearl--pick-source-candidates nil saved nil)))
+ (should (assoc "[saved → Personal] Mine" cands))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-unknown-team-renders-q ()
+ "A synced entry whose team-id isn't in TEAMS renders `[saved \\u2192 ?] <Name>'."
+ (let* ((teams '(((id . "other-id") (key . "OTH") (name . "Other"))))
+ (saved '(("Stale" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "gone-team-id")))
+ (cands (pearl--pick-source-candidates nil saved teams)))
+ (should (assoc "[saved → ?] Stale" cands))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-no-teams-arg-renders-q ()
+ "When TEAMS is nil and an entry has a non-nil team-id, scope is `?'."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "team-x"))))
+ (should (assoc "[saved → ?] S"
+ (pearl--pick-source-candidates nil saved nil)))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-saved-query-dispatches-as-view ()
+ "A synced entry's source plist has :type view with :id = :linear-view-id."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid-X"
+ :linear-view-team-id nil)))
+ (cands (pearl--pick-source-candidates nil saved nil))
+ (source (cdr (assoc "[saved → Personal] S" cands))))
+ (should (eq 'view (plist-get source :type)))
+ (should (equal "view-uuid-X" (plist-get source :id)))
+ (should (equal "S" (plist-get source :name)))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-source-includes-url-when-stored ()
+ "A synced entry carrying `:linear-view-url' surfaces it in the source plist
+so `pearl-open-current-view-in-linear' can dispatch to the browser."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid-X"
+ :linear-view-team-id nil
+ :linear-view-url "https://linear.app/ws/view/abc")))
+ (cands (pearl--pick-source-candidates nil saved nil))
+ (source (cdr (assoc "[saved → Personal] S" cands))))
+ (should (equal "https://linear.app/ws/view/abc"
+ (plist-get source :url)))))
+
+(ert-deftest test-pearl-pick-source-candidates-synced-source-no-url-when-not-stored ()
+ "An entry synced before `:linear-view-url' tracking landed has no URL in the
+source plist; the open-in-Linear command errors cleanly until the user re-syncs."
+ (let* ((saved '(("S" :filter (:open t)
+ :linear-view-id "view-uuid-X"
+ :linear-view-team-id nil)))
+ (cands (pearl--pick-source-candidates nil saved nil))
+ (source (cdr (assoc "[saved → Personal] S" cands))))
+ (should-not (plist-get source :url))))
+
+(ert-deftest test-pearl-pick-source-candidates-local-only-saved-query-keeps-saved-label ()
+ "A saved query without :linear-view-id still renders as `[saved] <Name>' and dispatches as filter."
+ (let* ((saved '(("Local" :filter (:open t))))
+ (cands (pearl--pick-source-candidates nil saved '()))
+ (source (cdr (assoc "[saved] Local" cands))))
+ (should source)
+ (should (eq 'filter (plist-get source :type)))
+ (should (equal '(:open t) (plist-get source :filter)))))
+
+(ert-deftest test-pearl-pick-source-candidates-mixed-local-and-synced ()
+ "A picker mixing local-only and synced entries renders both label styles."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (saved '(("Local thing" :filter (:open t))
+ ("Synced thing" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id "team-eng-id")))
+ (cands (pearl--pick-source-candidates nil saved teams))
+ (labels (mapcar #'car cands)))
+ (should (member "[saved] Local thing" labels))
+ (should (member "[saved → Engineering] Synced thing" labels))))
+
+;;; pearl--saved-query-scope-label
+
+(ert-deftest test-pearl-saved-query-scope-label-local-only-returns-nil ()
+ "An entry without :linear-view-id has no scope and returns nil."
+ (should (null (pearl--saved-query-scope-label '(:filter (:open t)) nil))))
+
+(ert-deftest test-pearl-saved-query-scope-label-personal-when-no-team-id ()
+ "A synced entry with nil team-id renders as \"Personal\"."
+ (should (equal "Personal"
+ (pearl--saved-query-scope-label
+ '(:filter (:open t) :linear-view-id "v" :linear-view-team-id nil)
+ nil))))
+
+(ert-deftest test-pearl-saved-query-scope-label-resolves-team-name-from-teams ()
+ "A synced entry with a team-id resolves to that team's name."
+ (let ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering")))))
+ (should (equal "Engineering"
+ (pearl--saved-query-scope-label
+ '(:filter (:open t)
+ :linear-view-id "v" :linear-view-team-id "team-eng-id")
+ teams)))))
+
+(ert-deftest test-pearl-saved-query-scope-label-unknown-team-id-renders-q ()
+ "An entry whose team-id doesn't appear in TEAMS renders \"?\"."
+ (let ((teams '(((id . "other-id") (name . "Other")))))
+ (should (equal "?"
+ (pearl--saved-query-scope-label
+ '(:filter (:open t)
+ :linear-view-id "v" :linear-view-team-id "gone")
+ teams)))))
+
;;; open-favorite-url
(ert-deftest test-pearl-open-favorite-url-with-url-browses ()
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
index ebf4622..830f738 100644
--- a/tests/test-pearl-saved-query-sync.el
+++ b/tests/test-pearl-saved-query-sync.el
@@ -239,6 +239,34 @@
(pearl--save-query-mark-synced "Q" nil nil "view-uuid")
:type 'user-error)))
+(ert-deftest test-pearl-save-query-mark-synced-stores-view-url-when-given ()
+ "The optional VIEW-URL arg lands in the entry as :linear-view-url."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t))))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced
+ "Q" nil nil "view-uuid" "https://linear.app/ws/view/abc"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "https://linear.app/ws/view/abc"
+ (plist-get spec :linear-view-url))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-keeps-prior-url-when-omitted ()
+ "Omitting VIEW-URL on a re-sync preserves any previously stored URL.
+Re-sync paths whose API response doesn't carry a URL shouldn't erase a
+URL the create path already wrote."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "old-id"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false
+ :linear-view-url "https://linear.app/ws/view/old")))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ ;; No VIEW-URL passed -- the prior URL should survive.
+ (pearl--save-query-mark-synced "Q" nil nil "new-id"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "https://linear.app/ws/view/old"
+ (plist-get spec :linear-view-url))))))
+
(ert-deftest test-pearl-save-query-mark-synced-overwrites-prior-sync-metadata ()
"Re-syncing an already-synced entry overwrites the four `:linear-view-*' keys."
(let ((pearl-saved-queries