From 60b51e1c996f1c8f152696946b9440f26ea9a956 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 25 May 2026 21:22:27 -0500 Subject: refactor(save): retire the immediate-push field setters for buffer-editors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured fields now reconcile at save (the previous commits), so the immediate-push setters are the duplicate path that made the package feel like it had two save models. I retired them. pearl-set-priority is deleted outright — priority is edited org-natively through the heading cookie. pearl-set-state, pearl-set-assignee, and pearl-set-labels become pearl-edit-state / -assignee / -labels: the same completing-read picker, but it writes the buffer representation (keyword/name/id, the assignee drawer, the labels drawer plus the live LINEAR-LABEL-IDS set) and marks the field dirty instead of pushing. The change goes out at the next pearl-save-issue / pearl-save-all. pearl-compose-current-description is renamed pearl-edit-description (its compose-and-push behavior is unchanged — v2 doesn't touch the compose buffers). The transient, the C-; L e keymap, the affordance preamble, and the README follow the new names, and the priority slots are dropped (no command — it's the org cookie). Deleting the setters left pearl--push-issue-field and pearl--priority-choices with no callers, so both are gone, along with the killed-buffer test that only exercised the removed helper. I filed a follow-up: the atomic savers' async commit callbacks need the same killed-buffer guard that helper carried. The setter command tests are rewritten to assert the buffer-write-and-no-push contract. The reconcile-and-push path they used to cover is exercised in test-pearl-save. --- README.org | 7 +- pearl.el | 153 +++++++++++------------------------ tests/test-integration-acceptance.el | 10 ++- tests/test-pearl-assignee-labels.el | 121 ++++++++++----------------- tests/test-pearl-bugfixes.el | 25 ------ tests/test-pearl-compose.el | 6 +- tests/test-pearl-fields.el | 73 ++++------------- tests/test-pearl-keymap.el | 19 ++--- tests/test-pearl-menu.el | 2 +- tests/test-pearl-output.el | 2 +- 10 files changed, 129 insertions(+), 289 deletions(-) diff --git a/README.org b/README.org index f10233b..37c2cb6 100644 --- a/README.org +++ b/README.org @@ -194,10 +194,9 @@ All issue commands work from anywhere inside an issue subtree. |-----------------------------------+--------------------------------------------------| | =pearl-sync-current-issue= | Push the edited description body | | =pearl-sync-current-issue-title= | Push the edited heading title | -| =pearl-set-priority= | Set None, Urgent, High, Medium, or Low | -| =pearl-set-state= | Set a workflow state from the issue's team | -| =pearl-set-assignee= | Set a team member as assignee | -| =pearl-set-labels= | Replace labels; empty selection clears them | +| =pearl-edit-state= | Pick a workflow state (pushed at next save) | +| =pearl-edit-assignee= | Pick a team member as assignee (at next save) | +| =pearl-edit-labels= | Pick labels; empty selection clears (at save) | | =pearl-add-comment= | Add a new Linear comment | | =pearl-edit-current-comment= | Push edits to one of your own comments | | =pearl-delete-current-comment= | Delete one of your own comments after confirming | diff --git a/pearl.el b/pearl.el index 9a17662..370ccb2 100644 --- a/pearl.el +++ b/pearl.el @@ -2613,7 +2613,7 @@ the new comment is the viewer's own, so it renders editable." issue-id marker (pearl--org-to-md org)))))))) ;;;###autoload -(defun pearl-compose-current-description () +(defun pearl-edit-description () "Edit the description of the Linear issue at point in an Org compose buffer. Pops the current description into a focused buffer; C-c C-c writes it back into the issue body and syncs to Linear through the usual conflict gate, C-c C-k @@ -2718,10 +2718,6 @@ the generic mutation the field commands share." :updated-at (cdr (assoc 'updatedAt issue)))))) (lambda (_error _response _data) (funcall callback '(:success nil)))))) -(defconst pearl--priority-choices - '(("None" . 0) ("Urgent" . 1) ("High" . 2) ("Medium" . 3) ("Low" . 4)) - "Linear priority names mapped to their numeric API values.") - (defun pearl--set-priority-cookie (priority-num) "Set the Org priority cookie on the heading at point from PRIORITY-NUM. 1-4 map to #A-#D; 0 (None) removes the cookie. The priority range is bound @@ -2737,46 +2733,6 @@ locally so #D is accepted regardless of the user's `org-priority' settings." (4 (org-priority ?D)) (_ (org-priority 'remove)))))) -;;;###autoload -(defun pearl--push-issue-field (issue-id marker fields on-success success-message fail-message) - "Push FIELDS (an `issueUpdate' input alist) for ISSUE-ID. -On success, run the ON-SUCCESS thunk at MARKER, report SUCCESS-MESSAGE, and -surface the buffer; on failure report FAIL-MESSAGE. The shared push-and-report -tail of the field-setter commands (priority/state/assignee/labels)." - (pearl--update-issue-async - issue-id fields - (lambda (result) - (if (plist-get result :success) - ;; The push may complete after the buffer was killed; only touch it - ;; when it's still live, but still report the (remote) success. - (progn - (when (buffer-live-p (marker-buffer marker)) - (save-excursion - (goto-char marker) - (funcall on-success)) - (pearl--surface-buffer (marker-buffer marker))) - (message "%s" success-message)) - (message "%s" fail-message))))) - -(defun pearl-set-priority (priority-name) - "Set the priority of the Linear issue at point to PRIORITY-NAME. -Interactively, completes over None/Urgent/High/Medium/Low. Pushes the numeric -priority to Linear and rewrites the heading cookie on success. Works from -anywhere inside an issue subtree." - (interactive - (list (completing-read "Priority: " pearl--priority-choices nil t))) - (save-excursion - (pearl--goto-issue-heading-or-error) - (let ((issue-id (org-entry-get nil "LINEAR-ID")) - (priority-num (cdr (assoc priority-name pearl--priority-choices))) - (marker (point-marker))) - (unless priority-num - (user-error "Unknown priority: %s" priority-name)) - (pearl--push-issue-field - issue-id marker `(("priority" . ,priority-num)) - (lambda () (pearl--set-priority-cookie priority-num)) - (format "Set %s priority to %s" issue-id priority-name) - (format "Failed to set priority for %s" issue-id))))) (defun pearl--set-heading-state (state-name state-id) "Update the heading at point to STATE-NAME / STATE-ID. @@ -2792,11 +2748,12 @@ second push back to Linear." (org-todo (pearl--map-linear-state-to-org state-name))))) ;;;###autoload -(defun pearl-set-state (state-name) - "Set the workflow state of the Linear issue at point to STATE-NAME. -Interactively, completes over the issue team's workflow states. Pushes the -resolved state id to Linear and updates the heading keyword and drawer on -success. Works from anywhere inside an issue subtree." +(defun pearl-edit-state (state-name) + "Set the workflow state of the issue at point to STATE-NAME in the buffer. +Completes over the issue team's workflow states and writes the keyword, name, +and id. The change is reconciled and pushed at the next save, not immediately. +Works from anywhere inside an issue subtree. This picker reaches any state, +including one a TODO-keyword cycle can't express." (interactive (let ((team-id (save-excursion (when (ignore-errors (org-back-to-heading t) t) @@ -2808,20 +2765,15 @@ success. Works from anywhere inside an issue subtree." nil t)))) (save-excursion (pearl--goto-issue-heading-or-error) - (let* ((issue-id (org-entry-get nil "LINEAR-ID")) - (team-id (org-entry-get nil "LINEAR-TEAM-ID")) - (marker (point-marker)) + (let* ((team-id (org-entry-get nil "LINEAR-TEAM-ID")) (states (and team-id (pearl--team-states team-id))) (state (seq-find (lambda (s) (string= (cdr (assoc 'name s)) state-name)) states)) (state-id (and state (cdr (assoc 'id state))))) (unless state-id (user-error "No workflow state named %s in this team" state-name)) - (pearl--push-issue-field - issue-id marker `(("stateId" . ,state-id)) - (lambda () (pearl--set-heading-state state-name state-id)) - (format "Set %s state to %s" issue-id state-name) - (format "Failed to set state for %s" issue-id))))) + (pearl--set-heading-state state-name state-id) + (message "Set state to %s (save to push)" state-name)))) (defun pearl--team-collection-names (kind team-id) "Return the display labels of the KIND collection for TEAM-ID, for completion." @@ -2829,11 +2781,12 @@ success. Works from anywhere inside an issue subtree." (and team-id (pearl--team-collection kind team-id)))) ;;;###autoload -(defun pearl-set-assignee (assignee-name) - "Set the assignee of the Linear issue at point to ASSIGNEE-NAME. -Interactively, completes over the issue team's members. Resolves the name to -a user id, pushes it, and updates the assignee drawer on success. Works from -anywhere inside an issue subtree." +(defun pearl-edit-assignee (assignee-name) + "Set the assignee of the issue at point to ASSIGNEE-NAME in the buffer. +Completes over the issue team's members, resolves the name to a user id, and +writes the assignee drawer; the change is reconciled and pushed at the next +`pearl-save-issue' / `pearl-save-all', not immediately. Works from anywhere +inside an issue subtree." (interactive (let ((team-id (save-excursion (when (ignore-errors (org-back-to-heading t) t) @@ -2843,28 +2796,23 @@ anywhere inside an issue subtree." nil t)))) (save-excursion (pearl--goto-issue-heading-or-error) - (let* ((issue-id (org-entry-get nil "LINEAR-ID")) - (team-id (org-entry-get nil "LINEAR-TEAM-ID")) - (marker (point-marker)) + (let* ((team-id (org-entry-get nil "LINEAR-TEAM-ID")) (assignee-id (and team-id (pearl--resolve-team-id 'members assignee-name team-id)))) (unless assignee-id (user-error "No team member matching %s" assignee-name)) - (pearl--push-issue-field - issue-id marker `(("assigneeId" . ,assignee-id)) - (lambda () - (org-entry-put nil "LINEAR-ASSIGNEE-NAME" assignee-name) - (org-entry-put nil "LINEAR-ASSIGNEE-ID" assignee-id)) - (format "Set %s assignee to %s" issue-id assignee-name) - (format "Failed to set assignee for %s" issue-id))))) + (org-entry-put nil "LINEAR-ASSIGNEE-NAME" assignee-name) + (org-entry-put nil "LINEAR-ASSIGNEE-ID" assignee-id) + (message "Set assignee to %s (save to push)" assignee-name)))) ;;;###autoload -(defun pearl-set-labels (label-names) - "Set the labels of the Linear issue at point to LABEL-NAMES. -Interactively, completes (multiple) over the issue team's labels; an empty -selection clears the labels. Resolves each name to a label id, pushes the id -list, and updates the labels drawer on success. Works from anywhere inside an -issue subtree." +(defun pearl-edit-labels (label-names) + "Set the labels of the issue at point to LABEL-NAMES in the buffer. +Completes (multiple) over the issue team's labels; an empty selection clears +them. Resolves each name to a label id and writes the labels drawer plus the +`LINEAR-LABEL-IDS' live set; the change is reconciled and pushed at the next +`pearl-save-issue' / `pearl-save-all', not immediately. Works from anywhere +inside an issue subtree." (interactive (let ((team-id (save-excursion (when (ignore-errors (org-back-to-heading t) t) @@ -2874,23 +2822,18 @@ issue subtree." (pearl--team-collection-names 'labels team-id))))) (save-excursion (pearl--goto-issue-heading-or-error) - (let* ((issue-id (org-entry-get nil "LINEAR-ID")) - (team-id (org-entry-get nil "LINEAR-TEAM-ID")) - (marker (point-marker)) + (let* ((team-id (org-entry-get nil "LINEAR-TEAM-ID")) (label-ids (mapcar (lambda (name) (or (and team-id (pearl--resolve-team-id 'labels name team-id)) (user-error "No label matching %s" name))) label-names))) - (pearl--push-issue-field - issue-id marker `(("labelIds" . ,label-ids)) - (lambda () - (org-entry-put nil "LINEAR-LABELS" - (format "[%s]" (mapconcat #'identity label-names ", ")))) - (format "Set %s labels to %s" issue-id - (if label-names (mapconcat #'identity label-names ", ") "none")) - (format "Failed to set labels for %s" issue-id))))) + (org-entry-put nil "LINEAR-LABELS" + (format "[%s]" (mapconcat #'identity label-names ", "))) + (org-entry-put nil "LINEAR-LABEL-IDS" (string-join label-ids " ")) + (message "Set labels to %s (save to push)" + (if label-names (mapconcat #'identity label-names ", ") "none"))))) ;;; User-facing Commands (Async) @@ -2952,9 +2895,9 @@ page cap was hit. Pure function, no side effects." (insert (format "#+LINEAR-TRUNCATED: %s\n" (if truncated "yes" "no"))) ;; Affordance preamble (org comments -- not rendered content). (insert "#\n") - (insert "# Body = the issue description; edit it, then M-x pearl-sync-current-issue to push.\n") + (insert "# Body = the issue description; edit it, then M-x pearl-save-issue to push.\n") (insert "# Comments subtree = the thread; add with M-x pearl-add-comment.\n") - (insert "# Drawer fields change via M-x pearl-set-priority / -state / -assignee / -labels.\n") + (insert "# State/assignee/labels: M-x pearl-edit-state / -assignee / -labels; priority: the org cookie (C-c ,). All push at save.\n") (insert "# Refresh with M-x pearl-refresh-current-view (whole file) or -current-issue (one).\n") (insert "\n") @@ -4693,12 +4636,11 @@ body stay." ("e" "save ticket" pearl-save-issue) ("E" "save all" pearl-save-all)] ["Edit" - ("D" "edit description" pearl-compose-current-description) + ("D" "edit description" pearl-edit-description) ("M" "edit comment" pearl-edit-current-comment) - ("P" "edit priority" pearl-set-priority) - ("s" "edit state" pearl-set-state) - ("a" "edit assignee" pearl-set-assignee) - ("L" "edit labels" pearl-set-labels)] + ("s" "edit state" pearl-edit-state) + ("a" "edit assignee" pearl-edit-assignee) + ("L" "edit labels" pearl-edit-labels)] ["New" ("n" "new ticket" pearl-new-issue) ("c" "new comment" pearl-add-comment)] @@ -4737,10 +4679,10 @@ body stay." ;; verb-matched label (e.g. "edit priority") without pearl depending on ;; which-key. Lookups still resolve straight to the command. ;; -;; The labels follow the edit/new verbs even though some commands keep older -;; names (`pearl-compose-current-description', `pearl-set-state', and -;; `pearl-add-comment'); aligning the command names is a separate task. The -;; `d c' delete-comment slot is intentionally empty until that command exists. +;; The labels follow the edit/new verbs. The edit commands are named to match +;; (`pearl-edit-description', `pearl-edit-state', ...); the new-comment command +;; keeps its `pearl-add-comment' name pending that rename. Priority has no edit +;; command -- it is edited org-natively via the heading cookie (C-c , / S-up). (defvar pearl-save-map (let ((map (make-sparse-keymap))) @@ -4751,12 +4693,11 @@ body stay." (defvar pearl-edit-map (let ((map (make-sparse-keymap))) - (define-key map "d" (cons "edit description" #'pearl-compose-current-description)) + (define-key map "d" (cons "edit description" #'pearl-edit-description)) (define-key map "c" (cons "edit comment" #'pearl-edit-current-comment)) - (define-key map "p" (cons "edit priority" #'pearl-set-priority)) - (define-key map "s" (cons "edit state" #'pearl-set-state)) - (define-key map "a" (cons "edit assignee" #'pearl-set-assignee)) - (define-key map "l" (cons "edit labels" #'pearl-set-labels)) + (define-key map "s" (cons "edit state" #'pearl-edit-state)) + (define-key map "a" (cons "edit assignee" #'pearl-edit-assignee)) + (define-key map "l" (cons "edit labels" #'pearl-edit-labels)) map) "Pearl edit commands; a sub-keymap of `pearl-prefix-map'.") diff --git a/tests/test-integration-acceptance.el b/tests/test-integration-acceptance.el index f83c891..de5f0e7 100644 --- a/tests/test-integration-acceptance.el +++ b/tests/test-integration-acceptance.el @@ -37,7 +37,7 @@ ;; - pearl-refresh-current-view -> --merge-query-result (in-place merge) ;; - pearl-sync-current-issue -> --sync-decision -> --update-issue-description-async ;; - pearl-add-comment -> --create-comment-async -> --append-comment-to-issue -;; - pearl-set-priority -> --push-issue-field -> --update-issue-async +;; - edit the priority cookie + pearl-save-issue -> --save-priority-field -> --update-issue-async ;;; Code: @@ -77,6 +77,7 @@ because the description-update mutation contains both `IssueDescription' and (pageInfo (hasNextPage . :json-false) (endCursor . "c")))))) ('fetch-desc '((data (issue (description . "Original body.") + (priority . 2) (updatedAt . "2026-05-24T00:00:00.000Z"))))) ('comment-create '((data (commentCreate (success . t) @@ -189,11 +190,14 @@ because the description-update mutation contains both `IssueDescription' and (goto-char (point-min)) (should (re-search-forward "^\\*\\*\\* Comments$" nil t)) (should (re-search-forward "looks good" nil t)) - ;; --- set priority from inside the subtree --- + ;; --- edit the priority cookie, then save (reconcile-on-save) --- (goto-char (point-min)) (re-search-forward "First issue") + (org-back-to-heading t) + (let ((org-priority-highest ?A) (org-priority-lowest ?D)) + (org-priority ?A)) ; [#B] (2) -> [#A] (1) (setq test-integration--ops nil) - (pearl-set-priority "Urgent") + (pearl-save-issue) (should (memq 'issue-update test-integration--ops))))) (provide 'test-integration-acceptance) diff --git a/tests/test-pearl-assignee-labels.el b/tests/test-pearl-assignee-labels.el index 7a4a352..e7be619 100644 --- a/tests/test-pearl-assignee-labels.el +++ b/tests/test-pearl-assignee-labels.el @@ -19,10 +19,11 @@ ;;; Commentary: -;; Tests for the two drawer-field commands that resolve names to ids: -;; `pearl-set-assignee' and `pearl-set-labels'. They push via -;; the generic `--update-issue-async' and update the LINEAR-ASSIGNEE / LABELS -;; drawer. The resolver and the mutation are stubbed. +;; Tests for the two picker commands that resolve names to ids: +;; `pearl-edit-assignee' and `pearl-edit-labels'. As save-model-v2 +;; buffer-editors they write the LINEAR-ASSIGNEE / LABELS drawer (and the live +;; id sets) but do NOT push -- the change reconciles at the next save (the push +;; path is covered in test-pearl-save). The resolver is stubbed. ;;; Code: @@ -38,57 +39,38 @@ (goto-char (point-min)) ,@body)) -;;; set-assignee +;;; edit-assignee (buffer-editor: writes the drawer, no push) -(ert-deftest test-pearl-set-assignee-pushes-id-and-updates-drawer () - "Setting an assignee resolves the name, pushes the id, and updates the drawer." +(ert-deftest test-pearl-edit-assignee-writes-drawer-no-push () + "edit-assignee resolves the name, writes the drawer, and does not push." (let ((pushed nil)) (test-pearl--in-org "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-ASSIGNEE-ID: old\n:LINEAR-ASSIGNEE-NAME: Someone\n:END:\n" (cl-letf (((symbol-function 'pearl--resolve-team-id) (lambda (_kind _name _team &optional _force) "u9")) ((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t))))) - (pearl-set-assignee "Craig") - (should (string= "u9" (cdr (assoc "assigneeId" pushed)))) + (lambda (&rest _) (setq pushed t)))) + (pearl-edit-assignee "Craig") + (should-not pushed) (should (string= "Craig" (org-entry-get nil "LINEAR-ASSIGNEE-NAME"))) (should (string= "u9" (org-entry-get nil "LINEAR-ASSIGNEE-ID"))))))) -(ert-deftest test-pearl-set-assignee-unresolvable-errors () - "An unresolvable assignee name signals a user error and pushes nothing." - (let ((pushed nil)) - (test-pearl--in-org - "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n" - (cl-letf (((symbol-function 'pearl--resolve-team-id) - (lambda (&rest _) nil)) - ((symbol-function 'pearl--update-issue-async) - (lambda (&rest _) (setq pushed t)))) - (should-error (pearl-set-assignee "Nobody") :type 'user-error) - (should-not pushed))))) +(ert-deftest test-pearl-edit-assignee-unresolvable-errors () + "An unresolvable assignee name signals a user error and writes nothing." + (test-pearl--in-org + "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n" + (cl-letf (((symbol-function 'pearl--resolve-team-id) (lambda (&rest _) nil))) + (should-error (pearl-edit-assignee "Nobody") :type 'user-error)))) -(ert-deftest test-pearl-set-assignee-not-on-issue-errors () - "Setting an assignee outside a Linear issue heading signals a user error." +(ert-deftest test-pearl-edit-assignee-not-on-issue-errors () + "edit-assignee outside a Linear issue heading signals a user error." (test-pearl--in-org "* Plain heading\nno id\n" - (should-error (pearl-set-assignee "Craig") :type 'user-error))) - -(ert-deftest test-pearl-set-assignee-failure-preserves-drawer () - "A failed assignee push attempts the mutation but leaves the drawer unchanged." - (let (pushed) - (test-pearl--in-org - "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-ASSIGNEE-ID: old\n:LINEAR-ASSIGNEE-NAME: Someone\n:END:\n" - (cl-letf (((symbol-function 'pearl--resolve-team-id) - (lambda (_kind _name _team &optional _force) "u9")) - ((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success nil))))) - (pearl-set-assignee "Craig") - (should (string= "u9" (cdr (assoc "assigneeId" pushed)))) - (should (string= "Someone" (org-entry-get nil "LINEAR-ASSIGNEE-NAME"))) - (should (string= "old" (org-entry-get nil "LINEAR-ASSIGNEE-ID"))))))) + (should-error (pearl-edit-assignee "Craig") :type 'user-error))) -;;; set-labels +;;; edit-labels (buffer-editor: writes the drawer + live ids, no push) -(ert-deftest test-pearl-set-labels-pushes-ids-and-updates-drawer () - "Setting labels resolves each name, pushes the id list, and updates the drawer." +(ert-deftest test-pearl-edit-labels-writes-names-and-ids-no-push () + "edit-labels resolves each name, writes LINEAR-LABELS + LINEAR-LABEL-IDS, no push." (let ((pushed nil)) (test-pearl--in-org "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-LABELS: []\n:END:\n" @@ -96,47 +78,30 @@ (lambda (_kind name _team &optional _force) (pcase name ("bug" "l1") ("p1" "l2") (_ nil)))) ((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t))))) - (pearl-set-labels '("bug" "p1")) - (should (equal '("l1" "l2") (cdr (assoc "labelIds" pushed)))) - (should (string= "[bug, p1]" (org-entry-get nil "LINEAR-LABELS"))))))) - -(ert-deftest test-pearl-set-labels-clear-pushes-empty () - "Clearing labels (empty list) pushes an empty id list and empties the drawer." - (let ((pushed 'unset)) - (test-pearl--in-org - "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-LABELS: [bug]\n:END:\n" - (cl-letf (((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t))))) - (pearl-set-labels '()) - (should (equal '() (cdr (assoc "labelIds" pushed)))) - (should (string= "[]" (org-entry-get nil "LINEAR-LABELS"))))))) + (lambda (&rest _) (setq pushed t)))) + (pearl-edit-labels '("bug" "p1")) + (should-not pushed) + (should (string= "[bug, p1]" (org-entry-get nil "LINEAR-LABELS"))) + (should (string= "l1 l2" (org-entry-get nil "LINEAR-LABEL-IDS"))))))) -(ert-deftest test-pearl-set-labels-unresolvable-errors () - "An unresolvable label name signals a user error and pushes nothing." +(ert-deftest test-pearl-edit-labels-clear-writes-empty () + "Clearing labels writes an empty drawer and empty id set, no push." (let ((pushed nil)) (test-pearl--in-org - "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n" - (cl-letf (((symbol-function 'pearl--resolve-team-id) - (lambda (&rest _) nil)) - ((symbol-function 'pearl--update-issue-async) + "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-LABELS: [bug]\n:LINEAR-LABEL-IDS: l1\n:END:\n" + (cl-letf (((symbol-function 'pearl--update-issue-async) (lambda (&rest _) (setq pushed t)))) - (should-error (pearl-set-labels '("ghost")) :type 'user-error) - (should-not pushed))))) - -(ert-deftest test-pearl-set-labels-failure-preserves-drawer () - "A failed labels push attempts the mutation but leaves the labels drawer unchanged." - (let (pushed) - (test-pearl--in-org - "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-LABELS: [bug]\n:END:\n" - (cl-letf (((symbol-function 'pearl--resolve-team-id) - (lambda (_kind name _team &optional _force) - (pcase name ("p1" "l2") (_ nil)))) - ((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success nil))))) - (pearl-set-labels '("p1")) - (should (equal '("l2") (cdr (assoc "labelIds" pushed)))) - (should (string= "[bug]" (org-entry-get nil "LINEAR-LABELS"))))))) + (pearl-edit-labels '()) + (should-not pushed) + (should (string= "[]" (org-entry-get nil "LINEAR-LABELS"))) + (should (string= "" (org-entry-get nil "LINEAR-LABEL-IDS"))))))) + +(ert-deftest test-pearl-edit-labels-unresolvable-errors () + "An unresolvable label name signals a user error and writes nothing." + (test-pearl--in-org + "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n" + (cl-letf (((symbol-function 'pearl--resolve-team-id) (lambda (&rest _) nil))) + (should-error (pearl-edit-labels '("ghost")) :type 'user-error)))) (provide 'test-pearl-assignee-labels) ;;; test-pearl-assignee-labels.el ends here diff --git a/tests/test-pearl-bugfixes.el b/tests/test-pearl-bugfixes.el index caab884..7f5c635 100644 --- a/tests/test-pearl-bugfixes.el +++ b/tests/test-pearl-bugfixes.el @@ -151,30 +151,5 @@ (should-error (pearl-run-saved-query "bad") :type 'user-error) (should-not fetched)))) -;;; Bug: async issue callbacks must be harmless if the buffer is killed - -(ert-deftest test-pearl-push-issue-field-killed-buffer-no-signal () - "A field push whose buffer is killed before the success callback does not signal." - (let ((buf (generate-new-buffer "pearl-pif-kill"))) - (unwind-protect - (let (marker) - (with-current-buffer buf - (insert "* h\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nbody\n") - (org-mode) - (goto-char (point-min)) - (setq marker (point-marker))) - ;; The update stub kills the buffer, then reports success; the success - ;; callback must not run its buffer ops (or signal) on a dead buffer. - (cl-letf (((symbol-function 'pearl--update-issue-async) - (lambda (_id _fields cb) (kill-buffer buf) (funcall cb '(:success t))))) - ;; Must complete without signaling; the on-success thunk (which would - ;; error) must not run because the buffer is dead. - (pearl--push-issue-field - "a" marker '(("priority" . 2)) - (lambda () (error "on-success must not run against a dead buffer")) - "ok" "fail") - (should-not (buffer-live-p buf)))) - (when (buffer-live-p buf) (kill-buffer buf))))) - (provide 'test-pearl-bugfixes) ;;; test-pearl-bugfixes.el ends here diff --git a/tests/test-pearl-compose.el b/tests/test-pearl-compose.el index bda75cb..a96a9ca 100644 --- a/tests/test-pearl-compose.el +++ b/tests/test-pearl-compose.el @@ -22,7 +22,7 @@ ;; Tests for the shared compose buffer (`pearl--compose-in-buffer' and its ;; submit/abort/body helpers) and the two commands wired onto it: ;; `pearl-add-comment' (interactive composer path) and -;; `pearl-compose-current-description'. The body is extracted below a read-only +;; `pearl-edit-description'. The body is extracted below a read-only ;; header and converted Org->Markdown by the callers. ;;; Code: @@ -129,7 +129,7 @@ Binds `captured' to the value the on-finish receives (or `:none'). Stubs (should-not composed) (should (string= "literal body" sent)))))) -;;; compose-current-description wiring +;;; edit-description wiring (ert-deftest test-pearl-compose-description-seeds-body-and-syncs () "The description composer seeds the current body and, on submit, writes it back and syncs." @@ -141,7 +141,7 @@ Binds `captured' to the value the on-finish receives (or `:none'). Stubs (funcall on-finish "Edited description"))) ((symbol-function 'pearl-sync-current-issue) (lambda () (setq synced t)))) - (pearl-compose-current-description) + (pearl-edit-description) ;; seeded with the existing body (should (string= "Body." seeded)) ;; the edited text was written into the issue body, then synced diff --git a/tests/test-pearl-fields.el b/tests/test-pearl-fields.el index 3722486..2ad9679 100644 --- a/tests/test-pearl-fields.el +++ b/tests/test-pearl-fields.el @@ -86,38 +86,9 @@ (should (string-match-p "^\\*\\*\\* TODO Title" (thing-at-point 'line t))) (should-not (string-match-p "\\[#" (thing-at-point 'line t))))) -;;; pearl-set-priority - -(ert-deftest test-pearl-set-priority-pushes-and-updates-cookie () - "Setting priority pushes the numeric value and rewrites the cookie." - (let ((pushed nil)) - (test-pearl--in-org "*** TODO [#C] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n" - (cl-letf (((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t))))) - (re-search-forward "Title") - (pearl-set-priority "High") - (should (equal 2 (cdr (assoc "priority" pushed)))) - (goto-char (point-min)) - (should (string-match-p "\\[#B\\]" (thing-at-point 'line t))))))) - -(ert-deftest test-pearl-set-priority-not-on-issue-errors () - "Setting priority outside a Linear issue heading signals a user error." - (test-pearl--in-org "* Plain heading\nno id\n" - (should-error (pearl-set-priority "High") :type 'user-error))) - -(ert-deftest test-pearl-set-priority-failure-preserves-cookie () - "A failed priority push attempts the mutation but leaves the cookie unchanged." - (let (pushed) - (test-pearl--in-org "*** TODO [#C] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n" - (cl-letf (((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success nil))))) - (re-search-forward "Title") - (pearl-set-priority "High") - ;; the push was attempted with the intended value ... - (should (equal 2 (cdr (assoc "priority" pushed)))) - ;; ... but the local cookie is still #C, not #B. - (goto-char (point-min)) - (should (string-match-p "^\\*\\*\\* TODO \\[#C\\] Title" (thing-at-point 'line t))))))) +;; pearl-set-priority is gone: priority is edited org-natively via the heading +;; cookie and reconciled at save (see test-pearl-save). The cookie writer +;; `pearl--set-priority-cookie' above is still exercised. ;;; --set-heading-state @@ -140,10 +111,10 @@ (pearl--set-heading-state "Done" "s3") (should-not fired))))) -;;; pearl-set-state +;;; pearl-edit-state (buffer-editor: writes the representation, no push) -(ert-deftest test-pearl-set-state-pushes-id-and-updates-heading () - "Setting state resolves the name to an id, pushes it, and updates the heading." +(ert-deftest test-pearl-edit-state-writes-heading-no-push () + "edit-state resolves the name, writes keyword/name/id, and does not push." (let ((pushed nil)) (test-pearl--in-org "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n" @@ -151,34 +122,18 @@ (lambda (_team) '(((id . "s1") (name . "Todo")) ((id . "s2") (name . "In Progress"))))) ((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t))))) - (pearl-set-state "In Progress") - (should (string= "s2" (cdr (assoc "stateId" pushed)))) + (lambda (&rest _) (setq pushed t)))) + (pearl-edit-state "In Progress") + (should-not pushed) (goto-char (point-min)) (should (string-match-p "^\\*\\*\\* IN-PROGRESS " (thing-at-point 'line t))) - (should (string= "In Progress" (org-entry-get nil "LINEAR-STATE-NAME"))))))) + (should (string= "In Progress" (org-entry-get nil "LINEAR-STATE-NAME"))) + (should (string= "s2" (org-entry-get nil "LINEAR-STATE-ID"))))))) -(ert-deftest test-pearl-set-state-not-on-issue-errors () - "Setting state outside a Linear issue heading signals a user error." +(ert-deftest test-pearl-edit-state-not-on-issue-errors () + "edit-state outside a Linear issue heading signals a user error." (test-pearl--in-org "* Plain heading\nno id\n" - (should-error (pearl-set-state "Done") :type 'user-error))) - -(ert-deftest test-pearl-set-state-failure-preserves-keyword-and-drawer () - "A failed state push attempts the mutation but leaves the keyword and drawer unchanged." - (let (pushed) - (test-pearl--in-org - "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-STATE-ID: s1\n:LINEAR-STATE-NAME: Todo\n:END:\n" - (cl-letf (((symbol-function 'pearl--team-states) - (lambda (_t) '(((id . "s1") (name . "Todo")) - ((id . "s2") (name . "In Progress"))))) - ((symbol-function 'pearl--update-issue-async) - (lambda (_id input cb) (setq pushed input) (funcall cb '(:success nil))))) - (pearl-set-state "In Progress") - (should (string= "s2" (cdr (assoc "stateId" pushed)))) - (goto-char (point-min)) - (should (string-match-p "^\\*\\*\\* TODO " (thing-at-point 'line t))) - (should (string= "Todo" (org-entry-get nil "LINEAR-STATE-NAME"))) - (should (string= "s1" (org-entry-get nil "LINEAR-STATE-ID"))))))) + (should-error (pearl-edit-state "Done") :type 'user-error))) (provide 'test-pearl-fields) ;;; test-pearl-fields.el ends here diff --git a/tests/test-pearl-keymap.el b/tests/test-pearl-keymap.el index 260b6fa..7cfc603 100644 --- a/tests/test-pearl-keymap.el +++ b/tests/test-pearl-keymap.el @@ -45,15 +45,16 @@ Walks MAP looking for a labeled binding (\"label\" . COMMAND)." (should (keymapp pearl-prefix-map))) (ert-deftest test-pearl-prefix-map-resolves-leaf-commands () - "Each chord resolves to its command (existing names, not yet renamed)." + "Each chord resolves to its command." (should (eq 'pearl-save-issue (lookup-key pearl-prefix-map (kbd "s s")))) (should (eq 'pearl-save-all (lookup-key pearl-prefix-map (kbd "s a")))) - (should (eq 'pearl-compose-current-description (lookup-key pearl-prefix-map (kbd "e d")))) + (should (eq 'pearl-edit-description (lookup-key pearl-prefix-map (kbd "e d")))) (should (eq 'pearl-edit-current-comment (lookup-key pearl-prefix-map (kbd "e c")))) - (should (eq 'pearl-set-priority (lookup-key pearl-prefix-map (kbd "e p")))) - (should (eq 'pearl-set-state (lookup-key pearl-prefix-map (kbd "e s")))) - (should (eq 'pearl-set-assignee (lookup-key pearl-prefix-map (kbd "e a")))) - (should (eq 'pearl-set-labels (lookup-key pearl-prefix-map (kbd "e l")))) + (should (eq 'pearl-edit-state (lookup-key pearl-prefix-map (kbd "e s")))) + (should (eq 'pearl-edit-assignee (lookup-key pearl-prefix-map (kbd "e a")))) + (should (eq 'pearl-edit-labels (lookup-key pearl-prefix-map (kbd "e l")))) + ;; priority has no edit command (org-native cookie); e p is unbound + (should-not (lookup-key pearl-prefix-map (kbd "e p"))) (should (eq 'pearl-new-issue (lookup-key pearl-prefix-map (kbd "n t")))) (should (eq 'pearl-add-comment (lookup-key pearl-prefix-map (kbd "n c")))) (should (eq 'pearl-delete-current-issue (lookup-key pearl-prefix-map (kbd "d t")))) @@ -77,10 +78,10 @@ Walks MAP looking for a labeled binding (\"label\" . COMMAND)." (ert-deftest test-pearl-prefix-map-which-key-labels () "Leaf bindings carry the edit/new verb labels; sub-prefixes carry +labels." - (should (string= "edit priority" - (test-pearl-keymap--label-of pearl-edit-map 'pearl-set-priority))) + (should (string= "edit state" + (test-pearl-keymap--label-of pearl-edit-map 'pearl-edit-state))) (should (string= "edit description" - (test-pearl-keymap--label-of pearl-edit-map 'pearl-compose-current-description))) + (test-pearl-keymap--label-of pearl-edit-map 'pearl-edit-description))) (should (string= "new ticket" (test-pearl-keymap--label-of pearl-new-map 'pearl-new-issue))) (should (string= "new comment" diff --git a/tests/test-pearl-menu.el b/tests/test-pearl-menu.el index 8af4bb8..5964d31 100644 --- a/tests/test-pearl-menu.el +++ b/tests/test-pearl-menu.el @@ -64,7 +64,7 @@ menu entry that still points at it fails here." pearl-run-saved-query pearl-save-issue pearl-save-all - pearl-set-state + pearl-edit-state pearl-add-comment pearl-new-issue pearl-delete-current-issue)) diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el index 6eafbb4..eb5c671 100644 --- a/tests/test-pearl-output.el +++ b/tests/test-pearl-output.el @@ -52,7 +52,7 @@ (should (string-match-p "^#\\+LINEAR-SOURCE: " out)) (should (string-match-p "^#\\+LINEAR-COUNT: 0$" out)) ;; affordance preamble is present as org comments, not content - (should (string-match-p "^# .*pearl-sync-current-issue" out)))) + (should (string-match-p "^# .*pearl-save-issue" out)))) (ert-deftest test-pearl-build-org-content-source-roundtrips () "The serialized source in the header reads back to the original plist." -- cgit v1.2.3