diff options
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 520 |
1 files changed, 459 insertions, 61 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'.") |
