aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 08:50:07 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 08:55:59 -0500
commit60a026be803e4fb2e56520dd9e2cc72ba6bfc0e3 (patch)
treea39964f035d60075d28320019cbcb5e5e42d0737
parent0aa115314f6cb0aefd6ad34cfb738e391a411d54 (diff)
downloadpearl-60a026be803e4fb2e56520dd9e2cc72ba6bfc0e3.tar.gz
pearl-60a026be803e4fb2e56520dd9e2cc72ba6bfc0e3.zip
feat(view-sync): pearl-sync-saved-query-to-linear + transient entry
pearl-sync-saved-query-to-linear promotes a local pearl-saved-queries entry to a Linear Custom View via customViewCreate, or updates an already-synced entry in place via customViewUpdate. This is Phase 2 of docs/saved-query-sync-spec.org. Re-syncing keeps the stored scope. First-time sync prompts for the destination (team + visibility), with each candidate spelling out the complete end-state. Same-name collisions in the chosen scope prompt Replace / Rename / Cancel. A successful sync extends the entry with :linear-view-id, :linear-view-team-id, :linear-view-shared, :linear-view-synced-at. If the customize-save-variable persist fails after a successful API call, the message names the orphan view's id explicitly so the user can re-sync with Replace to reconcile. I bound the command under C-; L f S in pearl-fetch-map and added ("S" "sync saved query") to the transient's Fetch group. filterData is the existing pearl--build-issue-filter output verbatim. The 2026-05-28 live probe confirmed the shape passes through customView.filterData with no translation needed. Four related fixes ride along: - Extended pearl-get-teams-async to fetch `key' alongside `id name'. It shares pearl--cache-teams with pearl--all-teams, so an async-first populate would otherwise leave the cache without `key' and silently break filter-team lookups. - Normalized the stored :linear-view-shared flag to a strict boolean at the re-sync read site. `:json-false' is truthy in Elisp, so an unnormalized stored false would flip the view to team-shared on re-sync without asking. - pearl--sync-saved-query-await now returns `(:success nil :timeout t)' on a wait-for elapse, so the caller can distinguish a timeout from a Linear-side rejection and tell the user the request may still complete server-side. - The success path now emits a single message via pearl--sync-record-or-orphan-error. The prior code clobbered the orphan-recoverable view-id with a second message. I hoisted the prompt-sentinel block (pearl--filter-cancel etc.) above its first user. The prior order produced a forward-reference compile error the moment any other change touched the file. I added 31 tests in test-pearl-saved-query-sync.el covering the scope candidates, the customViewCreate/Update parsing, the entry persistence (including the :json-false overwrite case), the orphan-id error path, the timeout sentinel, and the get-teams-async cache parity. The keymap and menu tests gained coverage for the new binding. 631 ert tests total green. make compile and make lint clean. Lower-severity findings filed as follow-ups: stale pearl--cache-views across a schema upgrade, empty filter encoding to null filterData, the pearl--save-query-mark-synced rebuild dropping unknown plist keys, and the scope-label rassoc rebuilding the candidate list to look up a string the picker already had.
-rw-r--r--pearl.el520
-rw-r--r--tests/test-pearl-keymap.el4
-rw-r--r--tests/test-pearl-menu.el1
-rw-r--r--tests/test-pearl-saved-query-sync.el379
4 files changed, 842 insertions, 62 deletions
diff --git a/pearl.el b/pearl.el
index cb5a256..9dfc2ef 100644
--- a/pearl.el
+++ b/pearl.el
@@ -473,7 +473,11 @@ CALLBACK is called with the list of teams on success."
(pearl--log "Fetching teams asynchronously")
(pearl--progress "Fetching teams...")
- (let* ((query "query { teams { nodes { id name } } }")
+ ;; Keep the field set in lockstep with `pearl--all-teams': both write the
+ ;; same `pearl--cache-teams', so an async populate followed by a sync read
+ ;; would leave the cache missing `key' and quietly break callers that filter
+ ;; teams by key.
+ (let* ((query "query { teams { nodes { id key name } } }")
(success-fn (lambda (response)
(if response
(let ((teams (cdr (assoc 'nodes (assoc 'teams (assoc 'data response))))))
@@ -1131,7 +1135,7 @@ 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) {
customViews(first: $first, after: $after) {
- nodes { id name description shared url }
+ nodes { id name description shared url team { id } }
pageInfo { hasNextPage endCursor }
}
}")
@@ -1143,6 +1147,70 @@ Each node carries id/name/description/shared/url. A non-nil FORCE refetches."
(setq pearl--cache-views views))
views)))
+(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/
+workspace view. SHARED is t or nil. FILTER-DATA is a compiled
+`IssueFilter' alist (see `pearl--build-issue-filter') -- Linear's
+`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!) {
+ customViewCreate(input: $input) {
+ success
+ customView { id name shared team { id key name } }
+ }
+}")
+ (input (append (list (cons "name" name)
+ (cons "shared" (if shared t :json-false))
+ (cons "filterData" filter-data))
+ (when team-id (list (cons "teamId" team-id)))))
+ (variables `(("input" . ,input)))
+ (success-fn
+ (lambda (response)
+ (let* ((cvc (cdr (assoc 'customViewCreate
+ (cdr (assoc 'data response)))))
+ (success (eq t (cdr (assoc 'success cvc))))
+ (view (cdr (assoc 'customView cvc))))
+ (funcall callback
+ (list :success (and success (not (null view)))
+ :view view)))))
+ (error-fn
+ (lambda (error _response _data)
+ (funcall callback
+ (list :success nil
+ :error (format "%s" error))))))
+ (pearl--graphql-request-async query variables success-fn error-fn)))
+
+(defun pearl--customview-update-async (view-id update-input callback)
+ "Update a Custom View on Linear by VIEW-ID.
+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!) {
+ customViewUpdate(id: $id, input: $input) {
+ success
+ customView { id name shared team { id key name } }
+ }
+}")
+ (variables `(("id" . ,view-id)
+ ("input" . ,update-input)))
+ (success-fn
+ (lambda (response)
+ (let* ((cvu (cdr (assoc 'customViewUpdate
+ (cdr (assoc 'data response)))))
+ (success (eq t (cdr (assoc 'success cvu))))
+ (view (cdr (assoc 'customView cvu))))
+ (funcall callback
+ (list :success (and success (not (null view)))
+ :view view)))))
+ (error-fn
+ (lambda (error _response _data)
+ (funcall callback
+ (list :success nil
+ :error (format "%s" error))))))
+ (pearl--graphql-request-async query variables success-fn error-fn)))
+
(defun pearl--page-issues (page-fn callback &optional max-pages)
"Accumulate issues across pages via PAGE-FN, then call CALLBACK with a result.
PAGE-FN is called as (PAGE-FN AFTER PAGE-CB); it fetches one page and invokes
@@ -1376,12 +1444,16 @@ trip on every one. Clear the cache to force a refresh."
(defun pearl--all-teams ()
"Return all teams, fetching once and caching in `pearl--cache-teams'.
The whole-file org sync resolves a team per heading, so caching here turns
-N blocking lookups into one. Shares the cache with the team selector."
+N blocking lookups into one. Shares the cache with the team selector.
+`key' is fetched alongside `id' and `name' because the filter compiler
+keys teams by `team.key.eq', so callers that need to map a stored filter
+back to a team need the key handy."
(or pearl--cache-teams
(let* ((query "query {
teams {
nodes {
id
+ key
name
}
}
@@ -3205,6 +3277,71 @@ produces the same heading order rather than reshuffling into noise."
`updatedAt', the only other field Linear's public ordering supports."
(if (eq sort 'created) 'createdAt 'updatedAt))
+;;; Sentinel infrastructure for filter / pick-one prompts
+;;
+;; These sentinel constants and helpers back the prompt UX shared by
+;; `pearl-run-saved-query', `pearl-delete-saved-query',
+;; `pearl-list-issues-filtered', `pearl-pick-source', and
+;; `pearl-sync-saved-query-to-linear'. Defined ahead of those commands so
+;; the byte-compiler resolves the references in order.
+
+(defconst pearl--filter-any "[ Any. ]"
+ "Sentinel candidate shown at the top of filter-dimension prompts.
+Picking it means \"no constraint on this dimension\" -- i.e. *any* value
+matches. Earlier drafts read \"[ None. ]\", which described what the user
+picked rather than what the filter does; the dimension's not absent, it's
+unconstrained. Always rendered first so the choice is visible rather
+than a hidden empty-input idiom.")
+
+(defconst pearl--filter-cancel "[ Cancel. ]"
+ "Sentinel candidate shown at the top of pick-an-existing-thing prompts
+(delete a saved query, run a saved query) where the opt-out reads as
+\"don't do this\" rather than \"no constraint.\" Cancelling these
+prompts is a real choice, not a missing one.")
+
+(defun pearl--filter-sentinel-value-p (value)
+ "Return non-nil when VALUE is a sentinel (any opt-out marker) or empty.
+A nil, an empty string, `pearl--filter-any', or `pearl--filter-cancel'
+all mean the user opted out (of constraining, or of acting)."
+ (or (null value)
+ (and (stringp value)
+ (or (string-empty-p value)
+ (string= value pearl--filter-any)
+ (string= value pearl--filter-cancel)))))
+
+(defun pearl--with-sentinel (sentinel candidates)
+ "Return CANDIDATES with SENTINEL prepended as the first option."
+ (cons sentinel candidates))
+
+(defun pearl--completion-table-keep-order (candidates)
+ "Return a completion table over CANDIDATES that preserves input order.
+The framework-side sort (vertico-sort-function, prescient, ivy, etc.)
+re-sorts alphabetically by default, which clobbers the sentinel as the
+topmost option whenever a real candidate sorts ahead of \"[\". Setting
+the table's `display-sort-function' to `identity' is the standard Emacs
+hook for \"these are pre-sorted, leave them alone\"."
+ (lambda (string pred action)
+ (if (eq action 'metadata)
+ '(metadata (display-sort-function . identity)
+ (cycle-sort-function . identity))
+ (complete-with-action action candidates string pred))))
+
+(defun pearl--read-yes-no (prompt &optional default)
+ "Ask PROMPT as a completing-read over (\"yes\" \"no\") and return t / nil.
+DEFAULT is the value RET picks without typing; \"yes\" if omitted. The
+candidate list is ordered with the default first so the most-common
+choice is what the user sees at the top, consistent with
+`pearl--with-sentinel'. Use this for non-destructive prompts where
+RET-through the defaults is a feature; keep `yes-or-no-p' for destructive
+prompts (delete, overwrite) where typing \"yes\" is a safety affordance."
+ (let* ((d (or default "yes"))
+ (other (if (string= d "yes") "no" "yes"))
+ (choice (completing-read prompt
+ (pearl--completion-table-keep-order
+ (list d other))
+ nil t nil nil d)))
+ (string= "yes" choice)))
+
;;;###autoload
(defun pearl-run-saved-query (name)
"Run the saved query NAME from `pearl-saved-queries'.
@@ -3348,63 +3485,6 @@ with just what the user set."
(when project (list :project project))
(when labels (list :labels labels))))
-(defconst pearl--filter-any "[ Any. ]"
- "Sentinel candidate shown at the top of filter-dimension prompts.
-Picking it means \"no constraint on this dimension\" -- i.e. *any* value
-matches. Earlier drafts read \"[ None. ]\", which described what the user
-picked rather than what the filter does; the dimension's not absent, it's
-unconstrained. Always rendered first so the choice is visible rather
-than a hidden empty-input idiom.")
-
-(defconst pearl--filter-cancel "[ Cancel. ]"
- "Sentinel candidate shown at the top of pick-an-existing-thing prompts
-(delete a saved query, run a saved query) where the opt-out reads as
-\"don't do this\" rather than \"no constraint.\" Cancelling these
-prompts is a real choice, not a missing one.")
-
-(defun pearl--filter-sentinel-value-p (value)
- "Return non-nil when VALUE is a sentinel (any opt-out marker) or empty.
-A nil, an empty string, `pearl--filter-any', or `pearl--filter-cancel'
-all mean the user opted out (of constraining, or of acting)."
- (or (null value)
- (and (stringp value)
- (or (string-empty-p value)
- (string= value pearl--filter-any)
- (string= value pearl--filter-cancel)))))
-
-(defun pearl--with-sentinel (sentinel candidates)
- "Return CANDIDATES with SENTINEL prepended as the first option."
- (cons sentinel candidates))
-
-(defun pearl--read-yes-no (prompt &optional default)
- "Ask PROMPT as a completing-read over (\"yes\" \"no\") and return t / nil.
-DEFAULT is the value RET picks without typing; \"yes\" if omitted. The
-candidate list is ordered with the default first so the most-common
-choice is what the user sees at the top, consistent with
-`pearl--with-sentinel'. Use this for non-destructive prompts where
-RET-through the defaults is a feature; keep `yes-or-no-p' for destructive
-prompts (delete, overwrite) where typing \"yes\" is a safety affordance."
- (let* ((d (or default "yes"))
- (other (if (string= d "yes") "no" "yes"))
- (choice (completing-read prompt
- (pearl--completion-table-keep-order
- (list d other))
- nil t nil nil d)))
- (string= "yes" choice)))
-
-(defun pearl--completion-table-keep-order (candidates)
- "Return a completion table over CANDIDATES that preserves input order.
-The framework-side sort (vertico-sort-function, prescient, ivy, etc.)
-re-sorts alphabetically by default, which clobbers the sentinel as the
-topmost option whenever a real candidate sorts ahead of \"[\". Setting
-the table's `display-sort-function' to `identity' is the standard Emacs
-hook for \"these are pre-sorted, leave them alone\"."
- (lambda (string pred action)
- (if (eq action 'metadata)
- '(metadata (display-sort-function . identity)
- (cycle-sort-function . identity))
- (complete-with-action action candidates string pred))))
-
(defun pearl--read-filter-interactively ()
"Build a filter plist by completing over the chosen team's fetched dimensions.
Picks a team first (scoping the rest), then offers open-only, state, project,
@@ -3520,6 +3600,321 @@ stored when given."
(ignore-errors
(customize-save-variable 'pearl-saved-queries pearl-saved-queries))))
+;;; Saved-query -> Linear view sync (see docs/saved-query-sync-spec.org)
+
+(defun pearl--sync-scope-candidates (teams filter-team-key)
+ "Build the (DISPLAY . PLIST) candidate list for the scope-and-visibility prompt.
+TEAMS is the team alist list returned by `pearl--all-teams' (each carries
+`id', `key', `name'). FILTER-TEAM-KEY is the saved query's filter `:team'
+value (a team key string) or nil.
+
+Each candidate's PLIST is `(:team-id ID-OR-NIL :team-name NAME :shared
+BOOL)'. The default candidate is first: `[ Team: <X>, visible to the
+team ]' when FILTER-TEAM-KEY names a known team, else `[ Personal, only
+I see it ]'. Remaining candidates appear in a stable order -- the
+Personal row, then each team alphabetically with the shared row first.
+The meaningless `Personal + shared' combination is absent."
+ (let* ((sorted-teams (sort (copy-sequence teams)
+ (lambda (a b)
+ (string< (or (cdr (assoc 'name a)) "")
+ (or (cdr (assoc 'name b)) "")))))
+ (filter-team
+ (and filter-team-key
+ (seq-find (lambda (tm)
+ (equal filter-team-key (cdr (assoc 'key tm))))
+ sorted-teams)))
+ (personal-row
+ (cons "[ Personal, only I see it ]"
+ (list :team-id nil :team-name "Personal" :shared nil)))
+ (team-rows
+ (cl-mapcan
+ (lambda (tm)
+ (let ((name (cdr (assoc 'name tm)))
+ (id (cdr (assoc 'id tm))))
+ (list
+ (cons (format "[ Team: %s, visible to the team ]" name)
+ (list :team-id id :team-name name :shared t))
+ (cons (format "[ Team: %s, only I see it ]" name)
+ (list :team-id id :team-name name :shared nil)))))
+ sorted-teams))
+ (all-rows (cons personal-row team-rows))
+ (default-display
+ (if filter-team
+ (format "[ Team: %s, visible to the team ]"
+ (cdr (assoc 'name filter-team)))
+ (car personal-row)))
+ (default-row (cl-find default-display all-rows
+ :key #'car :test #'string=)))
+ (if default-row
+ (cons default-row (cl-remove default-row all-rows :test #'eq))
+ all-rows)))
+
+(defun pearl--find-view-by-name-in-scope (views name team-id shared)
+ "Return the first VIEW in VIEWS whose name, team scope, and shared flag match.
+NAME is the view name. TEAM-ID is the team UUID the candidate should be
+scoped to (nil for a personal/workspace view). SHARED is t or nil.
+Returns the matching view alist or nil."
+ (let ((shared-canonical (if shared t :json-false)))
+ (seq-find
+ (lambda (v)
+ (and (string= name (or (cdr (assoc 'name v)) ""))
+ (let ((view-team-id (cdr (assoc 'id (cdr (assoc 'team v))))))
+ (equal view-team-id team-id))
+ (let ((view-shared (cdr (assoc 'shared v))))
+ ;; Linear returns `t' or `:json-false'; either match the canonical
+ ;; form, OR nil counts as personal/private.
+ (cond ((eq shared-canonical t) (eq view-shared t))
+ (t (or (eq view-shared :json-false) (null view-shared)))))))
+ views)))
+
+(defun pearl--save-query-mark-synced (name team-id shared view-id)
+ "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'."
+ (let ((entry (assoc name pearl-saved-queries)))
+ (unless entry
+ (user-error "No saved query named %s" name))
+ (let* ((spec (cdr entry))
+ (filter (plist-get spec :filter))
+ (sort (plist-get spec :sort))
+ (order (plist-get spec :order))
+ (synced-at (format-time-string "%Y-%m-%dT%H:%M:%SZ" nil t))
+ (new-spec (append (list :filter filter)
+ (when sort (list :sort sort))
+ (when order (list :order order))
+ (list :linear-view-id view-id
+ :linear-view-team-id team-id
+ :linear-view-shared shared
+ :linear-view-synced-at synced-at))))
+ (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)
+ "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."
+ (condition-case err
+ (progn
+ (pearl--save-query-mark-synced name team-id shared view-id)
+ (message "%s %s to Linear (view id %s)%s"
+ verb name view-id
+ (if scope-label (format " [%s]" scope-label) ""))
+ t)
+ (error
+ (message
+ "View %s on Linear as %s but local link couldn't be saved (%s). Re-run `pearl-sync-saved-query-to-linear' and pick Replace to reconcile."
+ (if (string= verb "Updated") "updated" "created")
+ view-id (error-message-string err))
+ nil)))
+
+(defun pearl--sync-saved-query-pick-scope (filter-plist)
+ "Prompt the user for the destination scope of a sync-up.
+FILTER-PLIST is the saved query's authoring filter (used to derive the
+default candidate). Returns the chosen scope plist
+`(:team-id ID-OR-NIL :team-name NAME :shared BOOL)', or nil when the
+user cancels via the cancel sentinel."
+ (let* ((teams (pearl--all-teams))
+ (cands (pearl--sync-scope-candidates teams (plist-get filter-plist :team)))
+ (displays (mapcar #'car cands))
+ (choice (completing-read
+ "Where does this view live? "
+ (pearl--completion-table-keep-order
+ (pearl--with-sentinel pearl--filter-cancel displays))
+ nil t)))
+ (unless (pearl--filter-sentinel-value-p choice)
+ (cdr (assoc choice cands)))))
+
+(defun pearl--sync-saved-query-collision-action (name)
+ "Prompt the user when a view named NAME already exists in the chosen scope.
+Returns one of the symbols `replace', `rename', `cancel'."
+ (let ((choice (completing-read
+ (format "A view named %s already exists in this scope; what now? "
+ name)
+ (pearl--completion-table-keep-order
+ (list "Replace" "Rename" "Cancel"))
+ nil t nil nil "Replace")))
+ (pcase choice
+ ("Replace" 'replace)
+ ("Rename" 'rename)
+ (_ 'cancel))))
+
+(defun pearl--sync-saved-query-await (async-fn &rest args)
+ "Run the async helper ASYNC-FN with ARGS, busy-waiting for its callback.
+ASYNC-FN is one of `pearl--customview-create-async' /
+`pearl--customview-update-async' -- both end by invoking a callback
+appended to ARGS with a result plist. Returns that plist on completion,
+or `(:success nil :timeout t)' if the wait elapsed without the callback
+firing -- callers can distinguish a timeout from a Linear-side rejection
+so the user isn't told the API refused something that never reached it."
+ (let ((done nil) (result nil))
+ (apply async-fn
+ (append args
+ (list (lambda (r) (setq result r done t)))))
+ (pearl--wait-for (lambda () done))
+ (if done result (list :success nil :timeout t))))
+
+;;;###autoload
+(defun pearl-sync-saved-query-to-linear (name)
+ "Sync the local saved query NAME up to Linear as a Custom View.
+First-time sync prompts for the destination scope (team and visibility),
+then calls `customViewCreate'. Re-syncing an already-synced entry calls
+`customViewUpdate' against the stored view id -- the stored team and
+shared flag stay (use Replace/Rename below to reset them). A same-name
+collision in the chosen scope prompts Replace / Rename / Cancel. On
+success the entry is extended with the four `:linear-view-*' keys via
+`customize-save-variable'. On a successful API call followed by a
+persist failure, the message names the orphan view's id explicitly so
+the user can re-sync with Replace to reconcile."
+ (interactive
+ (list (if (null pearl-saved-queries)
+ (user-error "No saved queries to sync")
+ (completing-read
+ "Sync saved query: "
+ (pearl--completion-table-keep-order
+ (pearl--with-sentinel pearl--filter-cancel
+ (mapcar #'car pearl-saved-queries)))
+ nil t))))
+ (when (pearl--filter-sentinel-value-p name)
+ (user-error "Cancelled; no saved query synced"))
+ (let ((entry (assoc name pearl-saved-queries)))
+ (unless entry
+ (user-error "No saved query named %s" name))
+ (let* ((spec (cdr entry))
+ (filter-plist (plist-get spec :filter))
+ (existing-view-id (plist-get spec :linear-view-id))
+ (existing-team-id (plist-get spec :linear-view-team-id))
+ ;; Normalize to a strict boolean. Stored values can be t, nil, or
+ ;; `:json-false' (from a prior cycle through the JSON layer); the
+ ;; downstream `(if shared t :json-false)' encoder treats every
+ ;; non-nil value as truthy, so an unnormalized `:json-false' would
+ ;; silently flip a personal view to team-shared.
+ (existing-shared (eq (plist-get spec :linear-view-shared) t))
+ (filter-data (pearl--build-issue-filter filter-plist))
+ (target-name name)
+ (scope (if existing-view-id
+ ;; Re-sync uses the stored scope.
+ (list :team-id existing-team-id
+ :team-name (or (and existing-team-id
+ (cdr (assoc 'name
+ (seq-find
+ (lambda (tm)
+ (equal existing-team-id
+ (cdr (assoc 'id tm))))
+ (pearl--all-teams)))))
+ "Personal")
+ :shared existing-shared)
+ (pearl--sync-saved-query-pick-scope filter-plist))))
+ (unless scope
+ (user-error "Cancelled; no saved query synced"))
+ ;; Validate the filter at the boundary so a malformed entry surfaces
+ ;; before the API call.
+ (pearl--validate-issue-filter filter-plist)
+ (let* ((team-id (plist-get scope :team-id))
+ (shared (plist-get scope :shared))
+ (scope-label (car (rassoc scope
+ (pearl--sync-scope-candidates
+ (pearl--all-teams)
+ (plist-get filter-plist :team))))))
+ (cond
+ ;; First-time sync: check for a same-name collision in the chosen
+ ;; scope and either update-by-id (Replace), re-prompt (Rename), or
+ ;; bail (Cancel).
+ ((null existing-view-id)
+ (let* ((collision (pearl--find-view-by-name-in-scope
+ (pearl--custom-views t)
+ target-name team-id shared))
+ (action (and collision
+ (pearl--sync-saved-query-collision-action
+ target-name))))
+ (pcase action
+ ('cancel
+ (message "Cancelled; %s left unsynced" target-name))
+ ('rename
+ (setq target-name
+ (read-string (format "New view name (was %s): " name)))
+ (when (or (null target-name) (string-empty-p target-name))
+ (user-error "Cancelled; no name entered"))
+ (pearl--sync-saved-query-do-create
+ name target-name team-id shared filter-data scope-label))
+ ('replace
+ (pearl--sync-saved-query-do-update
+ name (cdr (assoc 'id collision))
+ team-id shared filter-data scope-label))
+ (_
+ ;; No collision -- straight create.
+ (pearl--sync-saved-query-do-create
+ name target-name team-id shared filter-data scope-label)))))
+ ;; Re-sync: update by stored id; no collision check (the id targets
+ ;; the existing view directly).
+ (t
+ (pearl--sync-saved-query-do-update
+ name existing-view-id team-id shared filter-data scope-label)))))))
+
+(defun pearl--sync-saved-query-do-create (name target-name team-id shared
+ filter-data scope-label)
+ "Create a Linear view from saved query NAME, persisting the link on success.
+TARGET-NAME is the view name to send to Linear (usually NAME, but may
+differ when the user picked Rename to dodge a collision). TEAM-ID,
+SHARED, FILTER-DATA are the Linear API inputs. SCOPE-LABEL is the
+scope-and-visibility display string for the success message."
+ (pearl--progress "Creating Linear view %s..." target-name)
+ (let* ((result (pearl--sync-saved-query-await
+ #'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))))))
+ (cond
+ ((and success view-id)
+ (pearl--sync-record-or-orphan-error
+ name team-id shared view-id "Synced" scope-label))
+ ((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."
+ name pearl-request-timeout))
+ (t
+ (message "Failed to sync %s: %s" name
+ (or (plist-get result :error) "Linear refused the create"))))))
+
+(defun pearl--sync-saved-query-do-update (name view-id team-id shared
+ filter-data scope-label)
+ "Update an existing Linear view from saved query NAME by VIEW-ID.
+TEAM-ID, SHARED, FILTER-DATA are the Linear API inputs. SCOPE-LABEL is
+the scope-and-visibility display string for the success message."
+ (pearl--progress "Updating Linear view %s..." name)
+ (let* ((update-input (append (list (cons "filterData" filter-data)
+ (cons "shared" (if shared t :json-false)))
+ (when team-id
+ (list (cons "teamId" team-id)))))
+ (result (pearl--sync-saved-query-await
+ #'pearl--customview-update-async
+ view-id update-input))
+ (success (plist-get result :success)))
+ (cond
+ (success
+ (pearl--sync-record-or-orphan-error
+ name team-id shared view-id "Updated" scope-label))
+ ((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."
+ name pearl-request-timeout))
+ (t
+ (message "Failed to update Linear view for %s: %s" name
+ (or (plist-get result :error) "Linear refused the update"))))))
+
;;;###autoload
(defun pearl-list-issues-filtered (filter-plist &optional save-name)
"Build an ad-hoc issue filter interactively, run it, and render it.
@@ -5223,7 +5618,8 @@ body stay."
("p" "by project" pearl-list-issues-by-project)
("f" "build a filter" pearl-list-issues-filtered)
("v" "custom view" pearl-run-view)
- ("Q" "saved query" pearl-run-saved-query)]
+ ("Q" "saved query" pearl-run-saved-query)
+ ("S" "sync saved query" pearl-sync-saved-query-to-linear)]
["View"
("g" "refresh view" pearl-refresh-current-view)
("r" "refresh issue" pearl-refresh-current-issue)
@@ -5265,6 +5661,8 @@ body stay."
(define-key map "f" (cons "build a filter" #'pearl-list-issues-filtered))
(define-key map "v" (cons "custom view" #'pearl-run-view))
(define-key map "q" (cons "saved query" #'pearl-run-saved-query))
+ (define-key map "S" (cons "sync saved query to Linear"
+ #'pearl-sync-saved-query-to-linear))
map)
"Pearl fetch commands; a sub-keymap of `pearl-prefix-map'.")
diff --git a/tests/test-pearl-keymap.el b/tests/test-pearl-keymap.el
index d04e309..b1b5e23 100644
--- a/tests/test-pearl-keymap.el
+++ b/tests/test-pearl-keymap.el
@@ -72,7 +72,9 @@ The lowercase `c' is no longer a direct command -- it is the create prefix."
(should (eq 'pearl-list-issues-by-project (lookup-key pearl-prefix-map (kbd "f p"))))
(should (eq 'pearl-list-issues-filtered (lookup-key pearl-prefix-map (kbd "f f"))))
(should (eq 'pearl-run-view (lookup-key pearl-prefix-map (kbd "f v"))))
- (should (eq 'pearl-run-saved-query (lookup-key pearl-prefix-map (kbd "f q")))))
+ (should (eq 'pearl-run-saved-query (lookup-key pearl-prefix-map (kbd "f q"))))
+ (should (eq 'pearl-sync-saved-query-to-linear
+ (lookup-key pearl-prefix-map (kbd "f S")))))
(ert-deftest test-pearl-prefix-map-edit-group ()
"The edit group resolves each field-edit command."
diff --git a/tests/test-pearl-menu.el b/tests/test-pearl-menu.el
index 63b925c..c25c9a9 100644
--- a/tests/test-pearl-menu.el
+++ b/tests/test-pearl-menu.el
@@ -62,6 +62,7 @@ menu entry that still points at it fails here."
(dolist (expected '(pearl-list-issues
pearl-run-view
pearl-run-saved-query
+ pearl-sync-saved-query-to-linear
pearl-save-issue
pearl-save-all
pearl-edit-state
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
new file mode 100644
index 0000000..7a40566
--- /dev/null
+++ b/tests/test-pearl-saved-query-sync.el
@@ -0,0 +1,379 @@
+;;; test-pearl-saved-query-sync.el --- Tests for syncing saved queries to Linear views -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for `pearl-sync-saved-query-to-linear' and its helpers
+;; (`pearl--sync-scope-candidates', `pearl--find-view-by-name-in-scope',
+;; `pearl--customview-create-async', `pearl--customview-update-async',
+;; `pearl--save-query-mark-synced', `pearl--sync-record-or-orphan-error').
+;; See docs/saved-query-sync-spec.org.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"
+ (file-name-directory
+ (or load-file-name buffer-file-name))))
+(require 'testutil-request (expand-file-name "testutil-request.el"
+ (file-name-directory
+ (or load-file-name buffer-file-name))))
+(require 'cl-lib)
+
+;;; pearl--sync-scope-candidates
+
+(ert-deftest test-pearl-sync-scope-candidates-default-with-filter-team ()
+ "When the filter has `:team X', the default candidate is the team-shared row."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))
+ ((id . "team-mkt-id") (key . "MKT") (name . "Marketing"))))
+ (cands (pearl--sync-scope-candidates teams "ENG")))
+ (should (string= "[ Team: Engineering, visible to the team ]" (caar cands)))
+ (let ((plist (cdar cands)))
+ (should (equal "team-eng-id" (plist-get plist :team-id)))
+ (should (equal "Engineering" (plist-get plist :team-name)))
+ (should (eq t (plist-get plist :shared))))))
+
+(ert-deftest test-pearl-sync-scope-candidates-default-personal-no-filter-team ()
+ "Without `:team' in the filter, the default candidate is Personal."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (cands (pearl--sync-scope-candidates teams nil)))
+ (should (string= "[ Personal, only I see it ]" (caar cands)))
+ (let ((plist (cdar cands)))
+ (should (null (plist-get plist :team-id)))
+ (should (equal "Personal" (plist-get plist :team-name)))
+ (should (null (plist-get plist :shared))))))
+
+(ert-deftest test-pearl-sync-scope-candidates-unknown-filter-team-falls-back-to-personal ()
+ "An unknown `:team' key in the filter falls back to Personal as the default."
+ (let* ((teams '(((id . "id-a") (key . "A") (name . "Apple"))))
+ (cands (pearl--sync-scope-candidates teams "GONE")))
+ (should (string= "[ Personal, only I see it ]" (caar cands)))))
+
+(ert-deftest test-pearl-sync-scope-candidates-no-duplicate-default-row ()
+ "The default candidate appears only once even when it matches a real row."
+ (let* ((teams '(((id . "team-eng-id") (key . "ENG") (name . "Engineering"))))
+ (cands (pearl--sync-scope-candidates teams "ENG"))
+ (count (cl-count "[ Team: Engineering, visible to the team ]" cands
+ :key #'car :test #'string=)))
+ (should (= 1 count))))
+
+(ert-deftest test-pearl-sync-scope-candidates-includes-personal-and-both-team-rows ()
+ "The list carries one Personal row plus two rows per team."
+ (let* ((teams '(((id . "id-a") (key . "A") (name . "Apple"))
+ ((id . "id-b") (key . "B") (name . "Banana"))))
+ (cands (pearl--sync-scope-candidates teams nil)))
+ ;; 1 personal + 2 teams x 2 rows = 5
+ (should (= 5 (length cands)))
+ (dolist (display '("[ Personal, only I see it ]"
+ "[ Team: Apple, visible to the team ]"
+ "[ Team: Apple, only I see it ]"
+ "[ Team: Banana, visible to the team ]"
+ "[ Team: Banana, only I see it ]"))
+ (should (cl-find display cands :key #'car :test #'string=)))))
+
+(ert-deftest test-pearl-sync-scope-candidates-no-meaningless-personal-shared ()
+ "No `[ Personal, visible to the team ]' row exists -- it would be meaningless."
+ (let* ((teams '(((id . "id-a") (key . "A") (name . "Apple"))))
+ (cands (pearl--sync-scope-candidates teams nil)))
+ (should-not (cl-find "[ Personal, visible to the team ]" cands
+ :key #'car :test #'string=))))
+
+(ert-deftest test-pearl-sync-scope-candidates-teams-sorted-alphabetically ()
+ "Non-default team rows appear in alphabetical order by team name."
+ (let* ((teams '(((id . "id-z") (key . "Z") (name . "Zebra"))
+ ((id . "id-a") (key . "A") (name . "Apple"))
+ ((id . "id-m") (key . "M") (name . "Mango"))))
+ (cands (pearl--sync-scope-candidates teams nil))
+ (displays (mapcar #'car cands))
+ (apple-pos (cl-position "[ Team: Apple, visible to the team ]"
+ displays :test #'string=))
+ (mango-pos (cl-position "[ Team: Mango, visible to the team ]"
+ displays :test #'string=))
+ (zebra-pos (cl-position "[ Team: Zebra, visible to the team ]"
+ displays :test #'string=)))
+ (should (and apple-pos mango-pos zebra-pos
+ (< apple-pos mango-pos)
+ (< mango-pos zebra-pos)))))
+
+;;; pearl--find-view-by-name-in-scope
+
+(ert-deftest test-pearl-find-view-by-name-in-scope-matches-team-shared ()
+ "Finds a view by name + matching team id + shared flag."
+ (let ((views '(((id . "v1") (name . "A") (shared . t)
+ (team (id . "t1")))
+ ((id . "v2") (name . "A") (shared . :json-false)
+ (team (id . "t1")))
+ ((id . "v3") (name . "B") (shared . t)
+ (team . nil)))))
+ (should (equal "v1" (cdr (assoc 'id
+ (pearl--find-view-by-name-in-scope
+ views "A" "t1" t)))))
+ (should (equal "v2" (cdr (assoc 'id
+ (pearl--find-view-by-name-in-scope
+ views "A" "t1" nil)))))))
+
+(ert-deftest test-pearl-find-view-by-name-in-scope-matches-personal ()
+ "Finds a personal (no team) view when scope matches."
+ (let ((views '(((id . "v1") (name . "A") (shared . :json-false)
+ (team . nil)))))
+ (should (equal "v1" (cdr (assoc 'id
+ (pearl--find-view-by-name-in-scope
+ views "A" nil nil)))))))
+
+(ert-deftest test-pearl-find-view-by-name-in-scope-no-match-returns-nil ()
+ "Returns nil when nothing matches name or scope."
+ (let ((views '(((id . "v1") (name . "A") (shared . t)
+ (team (id . "t1"))))))
+ (should (null (pearl--find-view-by-name-in-scope views "A" "t2" t)))
+ (should (null (pearl--find-view-by-name-in-scope views "Other" "t1" t)))
+ (should (null (pearl--find-view-by-name-in-scope views "A" nil nil)))))
+
+;;; pearl--customview-create-async
+
+(ert-deftest test-pearl-customview-create-async-success ()
+ "A successful `customViewCreate' invokes the callback with `:success' t and the view."
+ (testutil-linear-with-response
+ '((data
+ (customViewCreate
+ (success . t)
+ (customView (id . "view-uuid") (name . "X")
+ (shared . :json-false) (team)))))
+ (let (result)
+ (pearl--customview-create-async
+ "X" nil nil
+ '(("state" ("type" ("nin" . ["completed"]))))
+ (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success)))
+ (should (equal "view-uuid"
+ (cdr (assoc 'id (plist-get result :view))))))))
+
+(ert-deftest test-pearl-customview-create-async-failure ()
+ "A non-success `customViewCreate' invokes the callback with `:success' nil."
+ (testutil-linear-with-response
+ '((data (customViewCreate (success . :json-false) (customView))))
+ (let (result)
+ (pearl--customview-create-async
+ "X" nil nil '() (lambda (r) (setq result r)))
+ (should-not (plist-get result :success)))))
+
+;;; pearl--customview-update-async
+
+(ert-deftest test-pearl-customview-update-async-success ()
+ "A successful `customViewUpdate' invokes the callback with `:success' t."
+ (testutil-linear-with-response
+ '((data
+ (customViewUpdate
+ (success . t)
+ (customView (id . "view-uuid") (name . "X")
+ (shared . t)
+ (team (id . "team-id"))))))
+ (let (result)
+ (pearl--customview-update-async
+ "view-uuid" '() (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success))))))
+
+(ert-deftest test-pearl-customview-update-async-failure ()
+ "A non-success `customViewUpdate' invokes the callback with `:success' nil."
+ (testutil-linear-with-response
+ '((data (customViewUpdate (success . :json-false) (customView))))
+ (let (result)
+ (pearl--customview-update-async
+ "view-uuid" '() (lambda (r) (setq result r)))
+ (should-not (plist-get result :success)))))
+
+;;; pearl--save-query-mark-synced
+
+(ert-deftest test-pearl-save-query-mark-synced-adds-the-four-keys ()
+ "After mark-synced, the entry carries `:linear-view-id', `-team-id', `-shared', `-synced-at'."
+ (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" "team-uuid" t "view-uuid"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "view-uuid" (plist-get spec :linear-view-id)))
+ (should (equal "team-uuid" (plist-get spec :linear-view-team-id)))
+ (should (eq t (plist-get spec :linear-view-shared)))
+ (should (stringp (plist-get spec :linear-view-synced-at)))
+ ;; Original filter survives
+ (should (equal '(:open t) (plist-get spec :filter))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-preserves-sort-and-order ()
+ "Mark-synced doesn't drop `:sort' or `:order' from the entry."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t) :sort updated :order desc)))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced "Q" nil nil "view-uuid"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (eq 'updated (plist-get spec :sort)))
+ (should (eq 'desc (plist-get spec :order))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-personal-nil-team ()
+ "Personal scope persists `:linear-view-team-id' as nil and `:linear-view-shared' nil."
+ (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"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (null (plist-get spec :linear-view-team-id)))
+ (should (null (plist-get spec :linear-view-shared))))))
+
+(ert-deftest test-pearl-save-query-mark-synced-unknown-name-signals-user-error ()
+ "Mark-synced on a name that doesn't exist signals a `user-error'."
+ (let ((pearl-saved-queries '()))
+ (should-error
+ (pearl--save-query-mark-synced "Q" nil nil "view-uuid")
+ :type 'user-error)))
+
+(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
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "OLD-id"
+ :linear-view-team-id "OLD-team"
+ :linear-view-shared :json-false
+ :linear-view-synced-at "1970-01-01T00:00:00Z")))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (_ _) nil)))
+ (pearl--save-query-mark-synced "Q" "NEW-team" t "NEW-id"))
+ (let ((spec (cdr (assoc "Q" pearl-saved-queries))))
+ (should (equal "NEW-id" (plist-get spec :linear-view-id)))
+ (should (equal "NEW-team" (plist-get spec :linear-view-team-id)))
+ (should (eq t (plist-get spec :linear-view-shared)))
+ (should-not (equal "1970-01-01T00:00:00Z"
+ (plist-get spec :linear-view-synced-at))))))
+
+;;; pearl--sync-record-or-orphan-error
+
+(ert-deftest test-pearl-sync-record-orphan-error-names-view-id-on-persist-failure ()
+ "When persist fails, the message names the orphan view id explicitly."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) (error "disk full")))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (let ((result (pearl--sync-record-or-orphan-error
+ "Q" nil nil "ORPHAN-UUID-XYZ" "Synced")))
+ (should (null result))
+ (should (string-match-p "ORPHAN-UUID-XYZ" msg))))))
+
+(ert-deftest test-pearl-sync-record-orphan-error-success-returns-t-and-includes-view-id ()
+ "When persist succeeds, the helper returns t and the message names the view id."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) t))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (let ((result (pearl--sync-record-or-orphan-error
+ "Q" nil nil "view-uuid" "Synced")))
+ (should (eq t result))
+ (should (string-match-p "view-uuid" msg))
+ (should (string-match-p "Q" msg))
+ (should (string-match-p "Synced" msg))))))
+
+(ert-deftest test-pearl-sync-record-orphan-error-includes-scope-label-when-given ()
+ "An optional SCOPE-LABEL is appended in brackets to the success message."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) t))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (pearl--sync-record-or-orphan-error
+ "Q" nil nil "view-uuid" "Synced" "[ Team: Eng, visible to the team ]")
+ (should (string-match-p "Team: Eng" msg)))))
+
+(ert-deftest test-pearl-sync-record-orphan-error-update-verb-rewrites-failure-text ()
+ "The failure message uses the lowercase verb (\"updated\") on an update path."
+ (let ((msg nil))
+ (cl-letf (((symbol-function 'pearl--save-query-mark-synced)
+ (lambda (&rest _) (error "disk full")))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq msg (apply #'format fmt args)))))
+ (pearl--sync-record-or-orphan-error
+ "Q" nil nil "view-uuid" "Updated")
+ (should (string-match-p "updated on Linear" msg)))))
+
+;;; pearl--sync-saved-query-await — timeout detection
+
+(ert-deftest test-pearl-sync-saved-query-await-marks-timeout-when-callback-misses ()
+ "A callback that never fires returns `(:success nil :timeout t)' rather than nil."
+ (let ((pearl-request-timeout 0.05)) ; ~50ms is plenty to fall through the wait loop
+ (cl-letf (((symbol-function 'noop-async)
+ (lambda (&rest _ignored) nil))) ; never invokes callback
+ (let ((result (pearl--sync-saved-query-await #'noop-async)))
+ (should (eq t (plist-get result :timeout)))
+ (should-not (plist-get result :success))))))
+
+(ert-deftest test-pearl-sync-saved-query-await-returns-callback-result-on-completion ()
+ "When the callback fires, the helper returns that result verbatim (no timeout flag)."
+ (cl-letf (((symbol-function 'sync-fire-async)
+ (lambda (cb) (funcall cb (list :success t :view '((id . "X")))))))
+ (let ((result (pearl--sync-saved-query-await #'sync-fire-async)))
+ (should (eq t (plist-get result :success)))
+ (should-not (plist-get result :timeout)))))
+
+;;; Re-sync normalizes a stored `:json-false' shared flag — guards the silent flip
+
+(ert-deftest test-pearl-sync-resync-normalizes-json-false-shared-to-personal ()
+ "Re-sync of an entry with `:linear-view-shared :json-false' must NOT flip it shared.
+`:json-false' is truthy in Elisp, so a stored `:json-false' would slip past
+`(if shared t :json-false)' in the encoder and silently make the view team-shared."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "view-id"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false
+ :linear-view-synced-at "2026-01-01T00:00:00Z"))))
+ (captured-input nil))
+ (cl-letf (((symbol-function 'pearl--all-teams) (lambda () '()))
+ ((symbol-function 'pearl--validate-issue-filter) (lambda (_) t))
+ ((symbol-function 'pearl--build-issue-filter)
+ (lambda (_) '(("state" ("type" ("nin" . ["completed"]))))))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'pearl--customview-update-async)
+ (lambda (_view-id input cb)
+ (setq captured-input input)
+ (funcall cb (list :success t
+ :view '((id . "view-id")
+ (shared . :json-false)))))))
+ (pearl-sync-saved-query-to-linear "Q")
+ (let ((shared-value (cdr (assoc "shared" captured-input))))
+ (should (eq :json-false shared-value))))))
+
+;;; Cache-shape parity — pearl-get-teams-async and pearl--all-teams must agree
+
+(ert-deftest test-pearl-get-teams-async-query-fetches-key ()
+ "`pearl-get-teams-async' must fetch `key' so it can't poison the cache.
+Both `pearl-get-teams-async' and `pearl--all-teams' write
+`pearl--cache-teams', and downstream callers read `key' from cached
+teams. If the async path queried only `id name', a fetch sequence
+async→sync would leave the cache without `key' and silently break
+team-key lookups."
+ (let (captured-query)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (query &rest _) (setq captured-query query))))
+ (pearl-get-teams-async)
+ (should captured-query)
+ (should (string-match-p "key" captured-query)))))
+
+(provide 'test-pearl-saved-query-sync)
+;;; test-pearl-saved-query-sync.el ends here