aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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