aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pearl.el106
-rw-r--r--tests/test-pearl-saved-query-sync.el128
2 files changed, 221 insertions, 13 deletions
diff --git a/pearl.el b/pearl.el
index 9dfc2ef..cb65ab3 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1182,6 +1182,31 @@ query takes. CALLBACK is invoked with a plist
:error (format "%s" error))))))
(pearl--graphql-request-async query variables success-fn error-fn)))
+(defun pearl--customview-delete-async (view-id callback)
+ "Delete a Custom View on Linear by VIEW-ID.
+CALLBACK is invoked with a plist
+`(:success BOOL :error STRING-OR-NIL)'. Linear's `customViewDelete' is
+a soft delete that drops the view into the workspace trash (recoverable
+within Linear's standard window)."
+ (let* ((query "mutation CustomViewDelete($id: String!) {
+ customViewDelete(id: $id) {
+ success
+ }
+}")
+ (variables `(("id" . ,view-id)))
+ (success-fn
+ (lambda (response)
+ (let* ((cvd (cdr (assoc 'customViewDelete
+ (cdr (assoc 'data response)))))
+ (success (eq t (cdr (assoc 'success cvd)))))
+ (funcall callback (list :success success)))))
+ (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
@@ -3560,13 +3585,27 @@ an id), or `pearl--filter-any' (any assignee, no scoping)."
:test #'string=))))
open state project labels assignee)))
+(defun pearl--delete-saved-query-local (name)
+ "Remove NAME from `pearl-saved-queries' and persist via Customize.
+The unconditional persistence path shared by the local-only delete and
+the synced-entry unlink branch."
+ (setq pearl-saved-queries
+ (cl-remove name pearl-saved-queries :key #'car :test #'string=))
+ (customize-save-variable 'pearl-saved-queries pearl-saved-queries))
+
;;;###autoload
(defun pearl-delete-saved-query (name)
"Delete the saved query NAME from `pearl-saved-queries' after confirmation.
Interactively, completes over the configured query names with
-`pearl--filter-cancel' at the top. Local-only: removes the entry from
-the customized list and persists via `customize-save-variable'. Linear
-is untouched (saved queries are local; syncing them up is a separate spec)."
+`pearl--filter-cancel' at the top. Local-only entries are removed from
+the customized list and persisted via `customize-save-variable'; entries
+that carry a `:linear-view-id' (i.e. were synced up to Linear) trigger a
+second yes-or-no prompt for whether to also delete the linked Linear
+view (=customViewDelete=). Picking NO at the second prompt unlinks the
+entry but leaves the Linear view in place; picking YES calls
+=customViewDelete= and, on API failure, asks whether to drop the local
+entry anyway (which leaves the Linear view orphaned -- the message names
+the id so it can be deleted by hand)."
(interactive
(list (if (null pearl-saved-queries)
(user-error "No saved queries to delete")
@@ -3581,12 +3620,53 @@ is untouched (saved queries are local; syncing them up is a separate spec)."
(message "Cancelled; no saved query deleted"))
((not (assoc name pearl-saved-queries))
(user-error "No saved query named %s" name))
- ((yes-or-no-p (format "Delete saved query %s? " name))
- (setq pearl-saved-queries
- (cl-remove name pearl-saved-queries :key #'car :test #'string=))
- (customize-save-variable 'pearl-saved-queries pearl-saved-queries)
- (message "Deleted saved query %s" name))
- (t (message "Kept saved query %s" name))))
+ ((not (yes-or-no-p (format "Delete saved query %s? " name)))
+ (message "Kept saved query %s" name))
+ (t
+ (let ((view-id (plist-get (cdr (assoc name pearl-saved-queries))
+ :linear-view-id)))
+ (cond
+ ;; Local-only entry: unchanged behavior.
+ ((null view-id)
+ (pearl--delete-saved-query-local name)
+ (message "Deleted saved query %s" name))
+ ;; Synced entry: ask whether to also drop the Linear view.
+ ((yes-or-no-p
+ (format "Also delete the linked Linear view (\"%s\")? " name))
+ (pearl--delete-saved-query-do-linear-delete name view-id))
+ (t
+ ;; Unlink only: drop the local entry, leave the Linear view alone.
+ (pearl--delete-saved-query-local name)
+ (message "Deleted saved query %s; Linear view kept (id %s)"
+ name view-id)))))))
+
+(defun pearl--delete-saved-query-do-linear-delete (name view-id)
+ "Call customViewDelete on VIEW-ID and remove NAME locally on success.
+On API failure, ask whether to drop the local entry anyway; if accepted,
+the message names the orphan view id so the user can delete it in Linear
+by hand."
+ (pearl--progress "Deleting Linear view for %s..." name)
+ (let* ((result (pearl--sync-saved-query-await
+ #'pearl--customview-delete-async view-id))
+ (success (plist-get result :success)))
+ (cond
+ (success
+ (pearl--delete-saved-query-local name)
+ (message "Deleted saved query %s and removed view from Linear" name))
+ ((plist-get result :timeout)
+ (message
+ "Delete of Linear view for %s timed out after %ds; saved query left in place -- the delete may still have completed (view id %s) -- re-run after the network recovers."
+ name pearl-request-timeout view-id))
+ ((yes-or-no-p
+ (format
+ "Linear delete failed (%s). Drop the local entry anyway? "
+ (or (plist-get result :error) "unknown error")))
+ (pearl--delete-saved-query-local name)
+ (message
+ "Deleted saved query %s; Linear view is orphaned (id %s, delete manually)"
+ name view-id))
+ (t
+ (message "Kept saved query %s; Linear view also untouched" name)))))
(defun pearl--save-query (name filter-plist &optional sort order)
"Save FILTER-PLIST as the saved query NAME, replacing any entry of that NAME.
@@ -3753,10 +3833,10 @@ Returns one of the symbols `replace', `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
+ASYNC-FN is one of the `pearl--customview-*-async' helpers (create,
+update, or delete) -- each ends 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))
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
index 7a40566..ebf4622 100644
--- a/tests/test-pearl-saved-query-sync.el
+++ b/tests/test-pearl-saved-query-sync.el
@@ -375,5 +375,133 @@ team-key lookups."
(should captured-query)
(should (string-match-p "key" captured-query)))))
+;;; pearl--customview-delete-async
+
+(ert-deftest test-pearl-customview-delete-async-success ()
+ "A successful `customViewDelete' invokes the callback with `:success' t."
+ (testutil-linear-with-response
+ '((data (customViewDelete (success . t))))
+ (let (result)
+ (pearl--customview-delete-async "view-uuid" (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success))))))
+
+(ert-deftest test-pearl-customview-delete-async-failure ()
+ "A non-success `customViewDelete' invokes the callback with `:success' nil."
+ (testutil-linear-with-response
+ '((data (customViewDelete (success . :json-false))))
+ (let (result)
+ (pearl--customview-delete-async "view-uuid" (lambda (r) (setq result r)))
+ (should-not (plist-get result :success)))))
+
+;;; pearl-delete-saved-query — synced-entry second-stage prompt
+
+(defun test-pearl--make-yes-no-stub (answers)
+ "Return a `yes-or-no-p' replacement that returns each ANSWERS in order.
+Each call pops the head; the test fails the call sequence assertion if
+more prompts fire than answers were prepared for."
+ (let ((remaining answers))
+ (lambda (&rest _)
+ (if remaining
+ (pop remaining)
+ (error "Unexpected extra yes-or-no-p prompt")))))
+
+(ert-deftest test-pearl-delete-saved-query-synced-yes-yes-calls-customview-delete ()
+ "Synced entry, both prompts yes: customViewDelete fires and the local entry is removed."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "view-uuid"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false))))
+ (delete-called-with nil))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (test-pearl--make-yes-no-stub '(t t)))
+ ((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'pearl--customview-delete-async)
+ (lambda (vid cb)
+ (setq delete-called-with vid)
+ (funcall cb (list :success t)))))
+ (pearl-delete-saved-query "Q")
+ (should (equal "view-uuid" delete-called-with))
+ (should-not (assoc "Q" pearl-saved-queries)))))
+
+(ert-deftest test-pearl-delete-saved-query-synced-yes-no-unlinks-only ()
+ "Synced entry, first prompt yes second prompt no: local entry removed, customViewDelete not called."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "view-uuid"))))
+ (delete-called nil))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (test-pearl--make-yes-no-stub '(t nil)))
+ ((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'pearl--customview-delete-async)
+ (lambda (_ _) (setq delete-called t))))
+ (pearl-delete-saved-query "Q")
+ (should-not delete-called)
+ (should-not (assoc "Q" pearl-saved-queries)))))
+
+(ert-deftest test-pearl-delete-saved-query-synced-api-failure-then-delete-anyway ()
+ "Synced entry, customViewDelete fails, user accepts delete-anyway prompt: local removed, message names the orphan id."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "ORPHAN-UUID"))))
+ (last-message nil))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (test-pearl--make-yes-no-stub '(t t t))) ; delete? + also-on-Linear? + delete-anyway?
+ ((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (setq last-message (apply #'format fmt args))))
+ ((symbol-function 'pearl--customview-delete-async)
+ (lambda (_ cb)
+ (funcall cb (list :success nil :error "permission denied")))))
+ (pearl-delete-saved-query "Q")
+ (should-not (assoc "Q" pearl-saved-queries))
+ (should (string-match-p "ORPHAN-UUID" last-message))
+ (should (string-match-p "orphan" last-message)))))
+
+(ert-deftest test-pearl-delete-saved-query-synced-api-failure-then-keep ()
+ "Synced entry, customViewDelete fails, user rejects delete-anyway: both kept untouched.
+Verifies the API call actually fires (so the keep branch only runs when the
+delete-anyway prompt explicitly declines), guarding against a future refactor
+that silently routes to the unlink branch and skips the API call."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)
+ :linear-view-id "view-uuid"))))
+ (delete-called-with nil))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (test-pearl--make-yes-no-stub '(t t nil))) ; yes / yes / no on delete-anyway
+ ((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'pearl--progress) (lambda (&rest _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'pearl--customview-delete-async)
+ (lambda (vid cb)
+ (setq delete-called-with vid)
+ (funcall cb (list :success nil :error "permission denied")))))
+ (pearl-delete-saved-query "Q")
+ (should (equal "view-uuid" delete-called-with))
+ (should (assoc "Q" pearl-saved-queries)))))
+
+(ert-deftest test-pearl-delete-saved-query-local-only-entry-unchanged-behavior ()
+ "Local-only entry (no `:linear-view-id'): one yes-or-no-p, no second prompt, no API call."
+ (let ((pearl-saved-queries
+ (copy-tree '(("Q" :filter (:open t)))))
+ (delete-called nil)
+ (prompt-count 0))
+ (cl-letf (((symbol-function 'yes-or-no-p)
+ (lambda (&rest _) (cl-incf prompt-count) t))
+ ((symbol-function 'customize-save-variable) (lambda (_ _) nil))
+ ((symbol-function 'message) (lambda (&rest _) nil))
+ ((symbol-function 'pearl--customview-delete-async)
+ (lambda (_ _) (setq delete-called t))))
+ (pearl-delete-saved-query "Q")
+ (should (= 1 prompt-count))
+ (should-not delete-called)
+ (should-not (assoc "Q" pearl-saved-queries)))))
+
(provide 'test-pearl-saved-query-sync)
;;; test-pearl-saved-query-sync.el ends here