aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-28 09:36:06 -0500
committerCraig Jennings <c@cjennings.net>2026-05-28 09:36:06 -0500
commit0975e7de0c7bfdc6f65bb64537d4eff1aeb09a7e (patch)
tree4b872db4e0417e173595409c63c7d1291015adbd
parentfd94237903c15883173e438a5af4ba4e6bfdbd46 (diff)
downloadpearl-0975e7de0c7bfdc6f65bb64537d4eff1aeb09a7e.tar.gz
pearl-0975e7de0c7bfdc6f65bb64537d4eff1aeb09a7e.zip
feat(view-sync): pearl-publish-current-source convenience wrapper
This is Phase 5 of docs/saved-query-sync-spec.org (ยง New: pearl-publish-current-source). When I'm reading a local saved query rendered in pearl and decide it should be a Linear view, I now have a one-chord publish instead of the M-x round-trip through pearl-sync-saved-query-to-linear with the name lookup. The new command reads the active buffer's #+LINEAR-SOURCE header and dispatches to pearl-sync-saved-query-to-linear with the name pre-filled when the source is a :type filter that matches an entry in pearl-saved-queries. Other shapes refuse with a clear message: no source header at all, the source is already a :type view (no publish needed), the type isn't filter, the source has no usable name (transient ad-hoc filter, or whitespace-only name), or the recorded name doesn't match any local saved query. I bound the command under C-; L f P in pearl-fetch-map. The transient gets ("U" "upload current source") in the Fetch group because P is taken in the transient's flat namespace, and the verb-prefix shape revisit task tracks reshaping that. 7 new tests cover each refusal branch and the dispatch path, including a regression for the whitespace-only :name case the pre-commit review surfaced. The prior check used string-empty-p, which let " " fall through to the no-matching-saved-query branch with a visibly-blank name in the error message. string-blank-p routes it to the cleaner no-name branch. The keymap and transient menu tests gained coverage for the new binding. 660 ert tests total green. make compile and make lint clean.
-rw-r--r--pearl.el38
-rw-r--r--tests/test-pearl-keymap.el4
-rw-r--r--tests/test-pearl-menu.el1
-rw-r--r--tests/test-pearl-saved-query-sync.el65
4 files changed, 106 insertions, 2 deletions
diff --git a/pearl.el b/pearl.el
index 724664b..61c4934 100644
--- a/pearl.el
+++ b/pearl.el
@@ -4040,6 +4040,39 @@ scope-and-visibility display string for the success message."
(message "Failed to sync %s: %s" name
(or (plist-get result :error) "Linear refused the create"))))))
+;;;###autoload
+(defun pearl-publish-current-source ()
+ "Publish the active buffer's local saved query to Linear as a Custom View.
+Convenience wrapper. Reads `#+LINEAR-SOURCE' from the current buffer.
+When it's a `:type filter' source whose `:name' matches a local saved
+query, dispatches to `pearl-sync-saved-query-to-linear' with that name
+pre-filled. Otherwise refuses with a clear message: the source is
+already a Linear view (no publish needed), the filter is transient (no
+name to sync under), or no local saved query matches the recorded name.
+
+Bound under \\='C-; L f P\\=' inside `pearl-mode' buffers; reachable
+through the transient as the `U' (upload) entry in the Fetch group."
+ (interactive)
+ (let* ((source (pearl--read-active-source))
+ (type (and source (plist-get source :type)))
+ (name (and source (plist-get source :name))))
+ (cond
+ ((null source)
+ (user-error "No active source header in this buffer; nothing to publish"))
+ ((eq type 'view)
+ (user-error "Source is already a Linear view (%s); no publish needed"
+ (or name "<unnamed>")))
+ ((not (eq type 'filter))
+ (user-error "Unsupported source type %s; only filter sources can be published"
+ type))
+ ((or (null name) (and (stringp name) (string-blank-p name)))
+ (user-error "Source has no name; save it first via pearl-list-issues-filtered then re-publish"))
+ ((not (assoc name pearl-saved-queries))
+ (user-error "No local saved query named %s; save it first via pearl-list-issues-filtered"
+ name))
+ (t
+ (pearl-sync-saved-query-to-linear name)))))
+
(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.
@@ -5772,7 +5805,8 @@ body stay."
("f" "build a filter" pearl-list-issues-filtered)
("v" "custom view" pearl-run-view)
("Q" "saved query" pearl-run-saved-query)
- ("S" "sync saved query" pearl-sync-saved-query-to-linear)]
+ ("S" "sync saved query" pearl-sync-saved-query-to-linear)
+ ("U" "upload current source" pearl-publish-current-source)]
["View"
("g" "refresh view" pearl-refresh-current-view)
("r" "refresh issue" pearl-refresh-current-issue)
@@ -5816,6 +5850,8 @@ body stay."
(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))
+ (define-key map "P" (cons "publish current source to Linear"
+ #'pearl-publish-current-source))
map)
"Pearl fetch commands; a sub-keymap of `pearl-prefix-map'.")
diff --git a/tests/test-pearl-keymap.el b/tests/test-pearl-keymap.el
index b1b5e23..1ae0d3d 100644
--- a/tests/test-pearl-keymap.el
+++ b/tests/test-pearl-keymap.el
@@ -74,7 +74,9 @@ The lowercase `c' is no longer a direct command -- it is the create prefix."
(should (eq 'pearl-run-view (lookup-key pearl-prefix-map (kbd "f v"))))
(should (eq 'pearl-run-saved-query (lookup-key pearl-prefix-map (kbd "f q"))))
(should (eq 'pearl-sync-saved-query-to-linear
- (lookup-key pearl-prefix-map (kbd "f S")))))
+ (lookup-key pearl-prefix-map (kbd "f S"))))
+ (should (eq 'pearl-publish-current-source
+ (lookup-key pearl-prefix-map (kbd "f P")))))
(ert-deftest test-pearl-prefix-map-edit-group ()
"The edit group resolves each field-edit command."
diff --git a/tests/test-pearl-menu.el b/tests/test-pearl-menu.el
index c25c9a9..e2ea550 100644
--- a/tests/test-pearl-menu.el
+++ b/tests/test-pearl-menu.el
@@ -63,6 +63,7 @@ menu entry that still points at it fails here."
pearl-run-view
pearl-run-saved-query
pearl-sync-saved-query-to-linear
+ pearl-publish-current-source
pearl-save-issue
pearl-save-all
pearl-edit-state
diff --git a/tests/test-pearl-saved-query-sync.el b/tests/test-pearl-saved-query-sync.el
index 830f738..f9bab00 100644
--- a/tests/test-pearl-saved-query-sync.el
+++ b/tests/test-pearl-saved-query-sync.el
@@ -387,6 +387,71 @@ URL the create path already wrote."
(let ((shared-value (cdr (assoc "shared" captured-input))))
(should (eq :json-false shared-value))))))
+;;; pearl-publish-current-source
+
+(defmacro test-pearl-publish--in-buffer (source-form &rest body)
+ "Run BODY in an org-mode temp buffer carrying SOURCE-FORM as #+LINEAR-SOURCE.
+SOURCE-FORM is the source plist (or nil for no source header)."
+ (declare (indent 1))
+ `(with-temp-buffer
+ (when ,source-form
+ (insert (format "#+title: test\n#+LINEAR-SOURCE: %s\n\n"
+ (prin1-to-string ,source-form))))
+ (org-mode)
+ ,@body))
+
+(ert-deftest test-pearl-publish-current-source-no-source-header-errors ()
+ "A buffer with no #+LINEAR-SOURCE signals a clear user-error."
+ (test-pearl-publish--in-buffer nil
+ (should-error (pearl-publish-current-source) :type 'user-error)))
+
+(ert-deftest test-pearl-publish-current-source-view-source-errors ()
+ "A view source signals user-error -- views are already on Linear."
+ (test-pearl-publish--in-buffer '(:type view :name "Eng dashboard" :id "v1")
+ (should-error (pearl-publish-current-source) :type 'user-error)))
+
+(ert-deftest test-pearl-publish-current-source-transient-filter-no-name-errors ()
+ "A :type filter with no :name (transient ad-hoc) signals user-error."
+ (test-pearl-publish--in-buffer '(:type filter :filter (:open t))
+ (should-error (pearl-publish-current-source) :type 'user-error)))
+
+(ert-deftest test-pearl-publish-current-source-no-matching-saved-query-errors ()
+ "A :type filter source whose name doesn't match any pearl-saved-queries entry errors."
+ (let ((pearl-saved-queries '(("Other" :filter (:open t)))))
+ (test-pearl-publish--in-buffer
+ '(:type filter :name "Gone" :filter (:open t))
+ (should-error (pearl-publish-current-source) :type 'user-error))))
+
+(ert-deftest test-pearl-publish-current-source-dispatches-to-sync-with-name ()
+ "A :type filter source whose name matches dispatches to pearl-sync-saved-query-to-linear."
+ (let ((pearl-saved-queries '(("Mine" :filter (:open t))))
+ (sync-called-with nil))
+ (cl-letf (((symbol-function 'pearl-sync-saved-query-to-linear)
+ (lambda (n) (setq sync-called-with n))))
+ (test-pearl-publish--in-buffer
+ '(:type filter :name "Mine" :filter (:open t))
+ (pearl-publish-current-source))
+ (should (equal "Mine" sync-called-with)))))
+
+(ert-deftest test-pearl-publish-current-source-empty-name-errors ()
+ "A :type filter source with an empty-string :name (degenerate) errors cleanly."
+ (test-pearl-publish--in-buffer
+ '(:type filter :name "" :filter (:open t))
+ (should-error (pearl-publish-current-source) :type 'user-error)))
+
+(ert-deftest test-pearl-publish-current-source-whitespace-name-errors ()
+ "A :type filter source with a whitespace-only :name takes the no-name branch
+rather than falling through to the no-matching-saved-query branch with a
+visibly-blank name in the error message."
+ (let ((pearl-saved-queries '()))
+ (test-pearl-publish--in-buffer
+ '(:type filter :name " " :filter (:open t))
+ (let ((err (should-error (pearl-publish-current-source)
+ :type 'user-error)))
+ ;; The message should name the source-has-no-name reason, not the
+ ;; no-matching-query reason.
+ (should (string-match-p "no name" (error-message-string err)))))))
+
;;; Cache-shape parity โ€” pearl-get-teams-async and pearl--all-teams must agree
(ert-deftest test-pearl-get-teams-async-query-fetches-key ()