diff options
| -rw-r--r-- | pearl.el | 106 | ||||
| -rw-r--r-- | tests/test-pearl-saved-query-sync.el | 128 |
2 files changed, 221 insertions, 13 deletions
@@ -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 |
