aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pearl.el145
-rw-r--r--tests/test-pearl-saved-query-sync.el83
2 files changed, 162 insertions, 66 deletions
diff --git a/pearl.el b/pearl.el
index ad88d4d..a8192b9 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3738,6 +3738,18 @@ stored when given."
;;; Saved-query -> Linear view sync (see docs/saved-query-sync-spec.org)
+(defun pearl--sync-scope-label (team-name shared)
+ "Return the display label for the scope identified by TEAM-NAME and SHARED.
+TEAM-NAME is either \"Personal\" or a real team name. SHARED is t
+\(visible to the team) or nil (only the viewer). The single source of
+truth so the picker, the re-sync path, and the success message all
+agree byte-for-byte."
+ (if (equal team-name "Personal")
+ "[ Personal, only I see it ]"
+ (format "[ Team: %s, %s ]"
+ team-name
+ (if shared "visible to the team" "only I see it"))))
+
(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
@@ -3760,7 +3772,7 @@ The meaningless `Personal + shared' combination is absent."
(equal filter-team-key (cdr (assoc 'key tm))))
sorted-teams)))
(personal-row
- (cons "[ Personal, only I see it ]"
+ (cons (pearl--sync-scope-label "Personal" nil)
(list :team-id nil :team-name "Personal" :shared nil)))
(team-rows
(cl-mapcan
@@ -3768,16 +3780,15 @@ The meaningless `Personal + shared' combination is absent."
(let ((name (cdr (assoc 'name tm)))
(id (cdr (assoc 'id tm))))
(list
- (cons (format "[ Team: %s, visible to the team ]" name)
+ (cons (pearl--sync-scope-label name t)
(list :team-id id :team-name name :shared t))
- (cons (format "[ Team: %s, only I see it ]" name)
+ (cons (pearl--sync-scope-label name nil)
(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)))
+ (pearl--sync-scope-label (cdr (assoc 'name filter-team)) t)
(car personal-row)))
(default-row (cl-find default-display all-rows
:key #'car :test #'string=)))
@@ -3873,9 +3884,11 @@ local link couldn't be saved."
(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."
+default candidate). Returns `(DISPLAY . PLIST)', or nil when the user
+cancels via the cancel sentinel. DISPLAY is the candidate string the
+user picked; PLIST is `(:team-id ID-OR-NIL :team-name NAME :shared
+BOOL)'. Returning both halves saves the caller from rebuilding the
+candidate list to look the display string back up via rassoc."
(let* ((teams (pearl--all-teams))
(cands (pearl--sync-scope-candidates teams (plist-get filter-plist :team)))
(displays (mapcar #'car cands))
@@ -3885,7 +3898,7 @@ user cancels via the cancel sentinel."
(pearl--with-sentinel pearl--filter-cancel displays))
nil t)))
(unless (pearl--filter-sentinel-value-p choice)
- (cdr (assoc choice cands)))))
+ (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.
@@ -3963,64 +3976,72 @@ the user can re-sync with Replace to reconcile."
(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
+ (let ((scope-pair
+ (if existing-view-id
+ ;; Re-sync: build the scope pair from stored metadata,
+ ;; resolving the team name from id (defaults to "Personal"
+ ;; when team-id is nil OR the team is no longer in the
+ ;; cache). The label comes from the same helper the
+ ;; picker uses so the success message matches what the
+ ;; user would have seen on first sync.
+ (let* ((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")))
+ (cons (pearl--sync-scope-label team-name existing-shared)
+ (list :team-id existing-team-id
+ :team-name team-name
+ :shared existing-shared)))
+ ;; First-time sync: pick-scope returns (display . plist) or
+ ;; nil (cancel).
+ (pearl--sync-saved-query-pick-scope filter-plist))))
+ (unless scope-pair
(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))))))
+ (let* ((scope-label (car scope-pair))
+ (scope (cdr scope-pair))
+ (team-id (plist-get scope :team-id))
+ (shared (plist-get scope :shared)))
(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))))))))
+ ;; 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)
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
index 8c98539..e237108 100644
--- a/tests/test-pearl-saved-query-sync.el
+++ b/tests/test-pearl-saved-query-sync.el
@@ -110,6 +110,60 @@
(< apple-pos mango-pos)
(< mango-pos zebra-pos)))))
+;;; pearl--sync-scope-label
+
+(ert-deftest test-pearl-sync-scope-label-personal-ignores-shared ()
+ "Personal scope always renders the personal label regardless of SHARED.
+A `Personal + shared' combination is meaningless on Linear (there's no
+team to share with), so the label collapses to the only sensible
+phrasing."
+ (should (equal "[ Personal, only I see it ]"
+ (pearl--sync-scope-label "Personal" nil)))
+ (should (equal "[ Personal, only I see it ]"
+ (pearl--sync-scope-label "Personal" t))))
+
+(ert-deftest test-pearl-sync-scope-label-team-renders-shared-or-private ()
+ "A team scope renders the shared or private variant per SHARED."
+ (should (equal "[ Team: Engineering, visible to the team ]"
+ (pearl--sync-scope-label "Engineering" t)))
+ (should (equal "[ Team: Engineering, only I see it ]"
+ (pearl--sync-scope-label "Engineering" nil))))
+
+(ert-deftest test-pearl-sync-saved-query-pick-scope-returns-display-plist-pair ()
+ "`pick-scope' returns the chosen candidate's (DISPLAY . PLIST) pair, not
+just the plist. The caller uses the display string for the success
+message without rebuilding the candidate list to look it up."
+ (cl-letf (((symbol-function 'pearl--all-teams)
+ (lambda () '(((id . "t1") (key . "ENG") (name . "Engineering")))))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) "[ Team: Engineering, visible to the team ]")))
+ (let ((pair (pearl--sync-saved-query-pick-scope '(:team "ENG"))))
+ (should (consp pair))
+ (should (equal "[ Team: Engineering, visible to the team ]"
+ (car pair)))
+ (should (equal "t1" (plist-get (cdr pair) :team-id)))
+ (should (eq t (plist-get (cdr pair) :shared))))))
+
+(ert-deftest test-pearl-sync-saved-query-pick-scope-returns-personal-pair ()
+ "Picking the Personal row returns the personal pair: display = the personal
+label, plist team-id nil + team-name \"Personal\" + shared nil."
+ (cl-letf (((symbol-function 'pearl--all-teams) (lambda () '()))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) "[ Personal, only I see it ]")))
+ (let ((pair (pearl--sync-saved-query-pick-scope '())))
+ (should (consp pair))
+ (should (equal "[ Personal, only I see it ]" (car pair)))
+ (should (null (plist-get (cdr pair) :team-id)))
+ (should (equal "Personal" (plist-get (cdr pair) :team-name)))
+ (should (null (plist-get (cdr pair) :shared))))))
+
+(ert-deftest test-pearl-sync-saved-query-pick-scope-returns-nil-on-cancel ()
+ "Picking the cancel sentinel returns nil so the caller can short-circuit."
+ (cl-letf (((symbol-function 'pearl--all-teams) (lambda () '()))
+ ((symbol-function 'completing-read)
+ (lambda (&rest _) pearl--filter-cancel)))
+ (should (null (pearl--sync-saved-query-pick-scope '())))))
+
;;; pearl--find-view-by-name-in-scope
(ert-deftest test-pearl-find-view-by-name-in-scope-matches-team-shared ()
@@ -418,16 +472,20 @@ erased on every re-sync."
;;; 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
+ "A first-time-sync 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)))
+ ;; Pick-scope returns (DISPLAY . PLIST); the empty-filter
+ ;; guard fires before this is consumed, but the stub
+ ;; shape should still match production.
+ (cons "[ Personal, only I see it ]"
+ (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")
@@ -435,6 +493,23 @@ user has already clicked through the scope picker."
(should-not scope-prompt-fired)
(should-not api-called))))
+(ert-deftest test-pearl-sync-empty-filter-refuses-resync-too ()
+ "A re-sync (entry already has `:linear-view-id') whose filter has been
+edited down to nil takes the same empty-filter guard, refusing without
+calling customViewUpdate. Guards against a future refactor accidentally
+hoisting the guard into the create-only branch."
+ (let ((pearl-saved-queries
+ (copy-tree '(("EmptyFilter" :filter nil
+ :linear-view-id "existing-view-uuid"
+ :linear-view-team-id nil
+ :linear-view-shared :json-false))))
+ (update-called nil))
+ (cl-letf (((symbol-function 'pearl--customview-update-async)
+ (lambda (&rest _) (setq update-called t))))
+ (should-error (pearl-sync-saved-query-to-linear "EmptyFilter")
+ :type 'user-error)
+ (should-not update-called))))
+
;;; pearl-publish-current-source
(defmacro test-pearl-publish--in-buffer (source-form &rest body)