diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-28 08:50:07 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-28 08:55:59 -0500 |
| commit | 60a026be803e4fb2e56520dd9e2cc72ba6bfc0e3 (patch) | |
| tree | a39964f035d60075d28320019cbcb5e5e42d0737 | |
| parent | 0aa115314f6cb0aefd6ad34cfb738e391a411d54 (diff) | |
| download | pearl-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.el | 520 | ||||
| -rw-r--r-- | tests/test-pearl-keymap.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-menu.el | 1 | ||||
| -rw-r--r-- | tests/test-pearl-saved-query-sync.el | 379 |
4 files changed, 842 insertions, 62 deletions
@@ -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 |
