aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 10:15:54 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 10:15:54 -0500
commit7939a598bc1f2e045dd4d4fe9e5dc4b7437147d5 (patch)
treebcbee40446999e8bf9f403196b401139217e7173
parentfc57dcac9a7c972fa59dbd0aded06ae0725000ca (diff)
downloadpearl-7939a598bc1f2e045dd4d4fe9e5dc4b7437147d5.tar.gz
pearl-7939a598bc1f2e045dd4d4fe9e5dc4b7437147d5.zip
fix(view-sync): refuse sync of a saved query whose filter is empty
A saved query with no filter constraints compiled to nil via pearl--build-issue-filter. The sync command then passed (cons "filterData" nil) to customViewCreate, which JSON-encoded as filterData=null. Linear's CustomViewCreateInput.filterData expects a JSON object, so the API rejected with an opaque error message after the user had already clicked through the scope picker. I added an early refusal in pearl-sync-saved-query-to-linear: when filter-data is nil, signal a user-error naming what's wrong before the scope prompt fires. The message tells the user to add a constraint and re-sync rather than leaving them to puzzle out an opaque API rejection. I restructured the let* nesting to put the empty-filter check between the bindings (where filter-data is computed) and the scope prompt (where the user is asked to pick a destination). The scope binding moved out of the outer let* into an inner let so it doesn't evaluate until after the filter-data guard runs. Two tests cover the guard. The first-time-sync test (no :linear-view-id on the entry) asserts neither the scope prompt nor customViewCreate fires. The re-sync test (entry has :linear-view-id) asserts customViewUpdate doesn't fire either. The re-sync test guards against a future refactor accidentally hoisting the check into the create-only branch. All 663 ert tests pass. make compile and make lint are clean.
-rw-r--r--pearl.el63
-rw-r--r--tests/test-pearl-saved-query-sync.el20
2 files changed, 56 insertions, 27 deletions
diff --git a/pearl.el b/pearl.el
index 39b7878..ad88d4d 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3953,32 +3953,41 @@ the user can re-sync with Replace to reconcile."
;; 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
+ (target-name name))
+ ;; Linear's CustomViewCreateInput.filterData expects a JSON object. An
+ ;; empty filter plist compiles to nil, which JSON-encodes as `null' and
+ ;; the API rejects with an opaque error. Refuse early -- before we
+ ;; prompt the user for scope -- so they see what's wrong instead of
+ ;; clicking through the scope picker only to hit "Linear refused".
+ (unless filter-data
+ (user-error
+ "Saved query %s has no filter constraints; Linear views need at least one constraint -- add one and re-sync"
+ name))
+ (let ((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).
@@ -4011,7 +4020,7 @@ the user can re-sync with Replace to reconcile."
;; the existing view directly).
(t
(pearl--sync-saved-query-do-update
- name existing-view-id team-id shared filter-data scope-label)))))))
+ 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)
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
index ceac5e6..8c98539 100644
--- a/tests/test-pearl-saved-query-sync.el
+++ b/tests/test-pearl-saved-query-sync.el
@@ -415,6 +415,26 @@ erased on every re-sync."
(let ((shared-value (cdr (assoc "shared" captured-input))))
(should (eq :json-false shared-value))))))
+;;; pearl-sync-saved-query-to-linear — empty filter refusal
+
+(ert-deftest test-pearl-sync-empty-filter-refuses-before-scope-prompt ()
+ "A saved query whose filter compiles to nil signals a clear user-error
+before any scope prompt fires. Without the guard, the sync would pass
+filterData=null to Linear, which rejects with an opaque error after the
+user has already clicked through the scope picker."
+ (let ((pearl-saved-queries (copy-tree '(("EmptyFilter" :filter nil))))
+ (scope-prompt-fired nil)
+ (api-called nil))
+ (cl-letf (((symbol-function 'pearl--sync-saved-query-pick-scope)
+ (lambda (&rest _) (setq scope-prompt-fired t)
+ (list :team-id nil :team-name "Personal" :shared nil)))
+ ((symbol-function 'pearl--customview-create-async)
+ (lambda (&rest _) (setq api-called t))))
+ (should-error (pearl-sync-saved-query-to-linear "EmptyFilter")
+ :type 'user-error)
+ (should-not scope-prompt-fired)
+ (should-not api-called))))
+
;;; pearl-publish-current-source
(defmacro test-pearl-publish--in-buffer (source-form &rest body)