diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-28 09:36:06 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-28 09:36:06 -0500 |
| commit | 0975e7de0c7bfdc6f65bb64537d4eff1aeb09a7e (patch) | |
| tree | 4b872db4e0417e173595409c63c7d1291015adbd | |
| parent | fd94237903c15883173e438a5af4ba4e6bfdbd46 (diff) | |
| download | pearl-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.el | 38 | ||||
| -rw-r--r-- | tests/test-pearl-keymap.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-menu.el | 1 | ||||
| -rw-r--r-- | tests/test-pearl-saved-query-sync.el | 65 |
4 files changed, 106 insertions, 2 deletions
@@ -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 () |
