aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el520
1 files changed, 459 insertions, 61 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'.")