diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-25 05:00:48 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-25 05:00:48 -0500 |
| commit | 9b17dc89fc25417e207943dafe8bf09ac211ae32 (patch) | |
| tree | c92779db74b0a4cccc6e38d7307c781aa62e9356 | |
| parent | 9f15d1823f388910f695da25ad5878312cc80330 (diff) | |
| download | pearl-9b17dc89fc25417e207943dafe8bf09ac211ae32.tar.gz pearl-9b17dc89fc25417e207943dafe8bf09ac211ae32.zip | |
feat: add pearl-delete-current-comment
Pearl could render, create, and edit comments but not delete one, so removing your own comment meant leaving Emacs for the web app. I added the delete command and filled the reserved C-; L d c keymap slot.
pearl-delete-current-comment runs from inside a comment's subtree. It's own-only, reusing the viewer gate from pearl-edit-current-comment: a comment authored by someone else, a bot, or an integration is refused with no commentDelete call. It confirms before the destructive mutation, and on success removes that comment's Org subtree, leaving sibling comments and the issue body untouched. pearl--delete-comment-async mirrors pearl--delete-issue-async.
The delete is permanent. I verified Linear's commentDelete against the live API: the comment is not found immediately after, with no restore path, so unlike issueDelete this isn't a recoverable soft delete. Because of that, a comment with unsaved local edits (or no stored hash) gets a stronger "discard your local edits" confirmation rather than the plain prompt.
I surfaced it as K in the transient Delete group and as d c in the prefix keymap, and inverted the keymap test that asserted d c was unbound.
| -rw-r--r-- | README.org | 5 | ||||
| -rw-r--r-- | pearl.el | 76 | ||||
| -rw-r--r-- | tests/test-pearl-comment-deletion.el | 237 | ||||
| -rw-r--r-- | tests/test-pearl-keymap.el | 6 |
4 files changed, 318 insertions, 6 deletions
@@ -19,7 +19,7 @@ Pearl (backronym: *Pearl Edits and Reflects Linear*) brings Linear issues into E - Keep structured fields in =LINEAR-*= properties: id, URL, team, state, priority, assignee, labels, timestamps, and sync hashes - Edit descriptions, titles, and your own comments in place, then push them with a three-way conflict check - Set priority, state, assignee, and labels by command, using Linear ids behind display-name completion -- Add comments, create issues, delete issues, and open the current issue or view in Linear +- Add and delete your own comments, create issues, delete issues, and open the current issue or view in Linear - Refresh the active view from the source recorded in the file, or refresh one issue at point - Optionally sync Org TODO keyword changes back to Linear workflow states - Use one transient dispatcher, =M-x pearl-menu=, for the whole command surface @@ -200,13 +200,14 @@ All issue commands work from anywhere inside an issue subtree. | =pearl-set-labels= | Replace labels; empty selection clears them | | =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 | | =pearl-delete-current-issue= | Soft-delete the current issue after confirmation | | =pearl-open-current-issue= | Open the issue URL in a browser | | =pearl-open-current-view-in-linear= | Open the active view in Linear | Description, title, and comment pushes use the same conflict gate: unchanged local text sends nothing; a local edit against an unchanged remote pushes; if both local and remote changed since fetch, Pearl refuses to clobber either side and reports the conflict. Destructive conflict choices stash local text in =*pearl-conflict-backup*= first. -Only comments you authored are editable. Pearl refuses edits to comments from another person, a bot, or an integration before making an API call. +Only comments you authored are editable or deletable. Pearl refuses edits and deletes to comments from another person, a bot, or an integration before making an API call. A comment delete is permanent (Linear has no restore for it), so deleting a comment with unsaved local edits prompts to confirm discarding them. *** Org TODO sync @@ -4068,6 +4068,23 @@ CALLBACK is called with a plist (:success BOOL)." (funcall callback (list :success success)))) (lambda (_error _response _data) (funcall callback '(:success nil)))))) +(defun pearl--delete-comment-async (comment-id callback) + "Delete COMMENT-ID on Linear via commentDelete. +CALLBACK receives a plist (:success BOOL). Verified live (2026-05-25): +`commentDelete' removes the comment immediately with no API restore path, so +unlike `issueDelete' this is not a recoverable soft delete." + (let ((query "mutation CommentDelete($id: String!) { + commentDelete(id: $id) { success } + }") + (variables `(("id" . ,comment-id)))) + (pearl--graphql-request-async + query variables + (lambda (data) + (let ((payload (cdr (assoc 'commentDelete (assoc 'data data))))) + (funcall callback + (list :success (eq t (cdr (assoc 'success payload))))))) + (lambda (_error _response _data) (funcall callback '(:success nil)))))) + (defun pearl--apply-comment-highlights (viewer-id) "Color every comment heading in the buffer by editability for VIEWER-ID. The viewer's own comments get `pearl-editable-comment'; all others get @@ -4135,6 +4152,61 @@ reported (refresh to reconcile)." marker (and viewer (plist-get viewer :id)) (lambda (outcome) (pearl--report-save-outcome outcome buf)))))))))) +;;;###autoload +(defun pearl-delete-current-comment () + "Delete the comment at point on Linear after confirmation, removing its subtree. +Works from anywhere inside a comment's subtree. Only your own comments are +deletable: a comment authored by anyone else (or a bot/integration) is refused +without a `commentDelete' call. The delete is permanent -- Linear's +`commentDelete' has no API restore path -- so a locally edited comment, or one +with no stored hash, gets a stronger discard-local-edits confirmation. On +success the comment's Org subtree is removed; sibling comments and the issue +body stay." + (interactive) + (save-excursion + (pearl--goto-heading-or-error "Not on a Linear comment") + (let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID")) + (author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID")) + (stored (org-entry-get nil "LINEAR-COMMENT-SHA256")) + (marker (point-marker)) + (buf (current-buffer))) + (unless comment-id + (user-error "Not on a Linear comment")) + (pearl--viewer-async + (lambda (viewer) + (cond + ((null viewer) + (message "Could not determine your Linear identity; not deleting")) + ((not (pearl--comment-editable-p author-id (plist-get viewer :id))) + (message "You can only delete your own comments")) + (t + ;; A clean comment (stored hash present and matching the rendered + ;; body) gets the plain prompt; a locally edited body or a missing + ;; hash (unknown provenance) gets the discard wording. The delete + ;; proceeds either way (decision 2). + (let* ((clean (and stored + (string= (secure-hash 'sha256 + (pearl--org-to-md + (org-with-point-at marker + (pearl--issue-body-at-point)))) + stored))) + (prompt (if clean + "Delete this comment from Linear and remove it here? " + "Delete this comment from Linear and discard your local edits here? "))) + (when (yes-or-no-p prompt) + (pearl--delete-comment-async + comment-id + (lambda (result) + (if (plist-get result :success) + (when (buffer-live-p buf) + (org-with-point-at marker + (org-back-to-heading t) + (delete-region (point) + (progn (org-end-of-subtree t t) (point)))) + (message "Deleted comment from Linear") + (pearl--surface-buffer buf)) + (message "Failed to delete comment"))))))))))))) + ;;; Transient Menu ;;;###autoload (autoload 'pearl-menu "pearl" nil t) @@ -4159,7 +4231,8 @@ reported (refresh to reconcile)." ("n" "new ticket" pearl-new-issue) ("c" "new comment" pearl-add-comment)] ["Delete" - ("k" "delete ticket" pearl-delete-current-issue)]] + ("k" "delete ticket" pearl-delete-current-issue) + ("K" "delete comment" pearl-delete-current-comment)]] ["Workspace" ["Fetch" ("l" "my open issues" pearl-list-issues) @@ -4225,6 +4298,7 @@ reported (refresh to reconcile)." (defvar pearl-delete-map (let ((map (make-sparse-keymap))) (define-key map "t" (cons "delete ticket" #'pearl-delete-current-issue)) + (define-key map "c" (cons "delete comment" #'pearl-delete-current-comment)) map) "Pearl delete commands; a sub-keymap of `pearl-prefix-map'.") diff --git a/tests/test-pearl-comment-deletion.el b/tests/test-pearl-comment-deletion.el new file mode 100644 index 0000000..359e12b --- /dev/null +++ b/tests/test-pearl-comment-deletion.el @@ -0,0 +1,237 @@ +;;; test-pearl-comment-deletion.el --- Tests for deleting a comment -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for `pearl-delete-current-comment' and `pearl--delete-comment-async' +;; (see docs/comment-deletion-spec.org). The command is own-only (reusing the +;; viewer / `pearl--comment-editable-p' gate), confirms before the destructive +;; `commentDelete' mutation, removes only the comment's subtree on success, and +;; ignores tag/save concerns. HTTP is stubbed at the request boundary. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) +(require 'testutil-request (expand-file-name "testutil-request.el")) +(require 'cl-lib) + +(defmacro test-pearl-del--in-org (content &rest body) + "Run BODY in an org-mode temp buffer holding CONTENT at point-min." + (declare (indent 1)) + `(let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))) + (org-element-use-cache nil)) + (with-temp-buffer + (insert ,content) + (org-mode) + (goto-char (point-min)) + ,@body))) + +(defun test-pearl-del--doc (&optional c1-stored-body) + "An issue with a body and two comments: own c1 (author u-me) + other c2. +C1's =LINEAR-COMMENT-SHA256= is the hash of C1-STORED-BODY's markdown, defaulting +to its visible body \"my comment\" (so the comment reads clean). Pass a +different string to make C1 read dirty, or the symbol `none' to omit the hash." + (let* ((shown "my comment") + (sha (cond ((eq c1-stored-body 'none) nil) + (t (secure-hash 'sha256 + (pearl--org-to-md (or c1-stored-body shown))))))) + (concat + "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nIssue body.\n" + "**** Comments\n" + "***** Craig --- 2026-05-24T09:00:00.000Z\n" + ":PROPERTIES:\n" + ":LINEAR-COMMENT-ID: c1\n" + ":LINEAR-COMMENT-AUTHOR-ID: u-me\n" + (if sha (format ":LINEAR-COMMENT-SHA256: %s\n" sha) "") + ":END:\n" shown "\n" + "***** Someone --- 2026-05-24T10:00:00.000Z\n" + ":PROPERTIES:\n" + ":LINEAR-COMMENT-ID: c2\n" + ":LINEAR-COMMENT-AUTHOR-ID: u-other\n" + ":LINEAR-COMMENT-SHA256: " (secure-hash 'sha256 "their comment") "\n" + ":END:\ntheir comment\n"))) + +(defun test-pearl-del--goto (needle) + "Move point onto the line matching NEEDLE." + (goto-char (point-min)) + (re-search-forward needle) + (beginning-of-line)) + +;;; --delete-comment-async (HTTP stubbed) + +(ert-deftest test-pearl-delete-comment-async-success () + "A successful commentDelete reports success." + (testutil-linear-with-response '((data (commentDelete (success . t)))) + (let (result) + (pearl--delete-comment-async "c1" (lambda (r) (setq result r))) + (should (eq t (plist-get result :success)))))) + +(ert-deftest test-pearl-delete-comment-async-soft-fail () + "A non-success / error commentDelete reports failure rather than erroring." + (testutil-linear-with-response '((data (commentDelete (success . :json-false)))) + (let (result) + (pearl--delete-comment-async "c1" (lambda (r) (setq result r))) + (should-not (plist-get result :success))))) + +;;; pearl-delete-current-comment + +(defmacro test-pearl-del--with-stubs (deleted confirm &rest body) + "Run BODY with delete/viewer/confirm stubbed. +DELETED is a symbol bound to a list collecting comment-ids passed to the delete +mutation (which reports success). CONFIRM is the `yes-or-no-p' return value. +Binds `prompt' to the last confirmation prompt string. Viewer is warm as u-me." + (declare (indent 2)) + `(let ((pearl--cache-viewer '(:id "u-me" :name "Me")) + (prompt nil)) + (cl-letf (((symbol-function 'pearl--delete-comment-async) + (lambda (id cb) (push id ,deleted) (funcall cb '(:success t)))) + ((symbol-function 'yes-or-no-p) + (lambda (p) (setq prompt p) ,confirm))) + (ignore prompt) + ,@body))) + +(ert-deftest test-pearl-delete-comment-not-on-comment-errors () + "Run on a non-comment heading: user-error, no delete mutation." + (test-pearl-del--in-org (test-pearl-del--doc) + (let ((deleted nil)) + (test-pearl-del--with-stubs deleted t + (test-pearl-del--goto "^\\*\\*\\* TODO") + (should-error (pearl-delete-current-comment) :type 'user-error) + (should-not deleted))))) + +(ert-deftest test-pearl-delete-comment-own-confirmed-removes-only-that-subtree () + "Own comment + confirm + success removes c1 only; c2 and the issue body stay." + (test-pearl-del--in-org (test-pearl-del--doc) + (let ((deleted nil)) + (test-pearl-del--with-stubs deleted t + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig") + (pearl-delete-current-comment) + (should (equal '("c1") deleted)) + (let ((s (buffer-string))) + (should-not (string-match-p "LINEAR-COMMENT-ID: c1" s)) + (should (string-match-p "LINEAR-COMMENT-ID: c2" s)) + (should (string-match-p "their comment" s)) + (should (string-match-p "Issue body\\." s))))))) + +(ert-deftest test-pearl-delete-comment-declined-no-mutation () + "Declining the confirmation deletes nothing and leaves the subtree intact." + (test-pearl-del--in-org (test-pearl-del--doc) + (let ((deleted nil)) + (test-pearl-del--with-stubs deleted nil + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig") + (pearl-delete-current-comment) + (should-not deleted) + (should (string-match-p "LINEAR-COMMENT-ID: c1" (buffer-string))))))) + +(ert-deftest test-pearl-delete-comment-non-own-refused () + "Another user's comment is refused with no delete mutation." + (test-pearl-del--in-org (test-pearl-del--doc) + (let ((deleted nil)) + (test-pearl-del--with-stubs deleted t + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Someone") + (pearl-delete-current-comment) + (should-not deleted) + (should (string-match-p "LINEAR-COMMENT-ID: c2" (buffer-string))))))) + +(ert-deftest test-pearl-delete-comment-viewer-failure-refused () + "A failed viewer lookup refuses with the identity message and no mutation." + (test-pearl-del--in-org (test-pearl-del--doc) + (let ((deleted nil) (msg nil) (pearl--cache-viewer nil)) + (cl-letf (((symbol-function 'pearl--viewer-async) + (lambda (cb) (funcall cb nil))) + ((symbol-function 'pearl--delete-comment-async) + (lambda (id cb) (push id deleted) (funcall cb '(:success t)))) + ((symbol-function 'message) + (lambda (fmt &rest args) (setq msg (apply #'format fmt args))))) + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig") + (pearl-delete-current-comment) + (should-not deleted) + (should (string-match-p "Linear identity" msg)))))) + +(ert-deftest test-pearl-delete-comment-failure-keeps-subtree () + "A failed commentDelete leaves the comment subtree in place." + (test-pearl-del--in-org (test-pearl-del--doc) + (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))) + (cl-letf (((symbol-function 'pearl--delete-comment-async) + (lambda (_id cb) (funcall cb '(:success nil)))) + ((symbol-function 'yes-or-no-p) (lambda (_p) t))) + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig") + (pearl-delete-current-comment) + (should (string-match-p "LINEAR-COMMENT-ID: c1" (buffer-string))))))) + +(ert-deftest test-pearl-delete-comment-clean-prompt-wording () + "A clean own comment uses the plain remove-it-here prompt." + (test-pearl-del--in-org (test-pearl-del--doc) + (let ((deleted nil) (captured nil)) + (cl-letf (((symbol-function 'pearl--delete-comment-async) + (lambda (id cb) (push id deleted) (funcall cb '(:success t)))) + ((symbol-function 'yes-or-no-p) (lambda (p) (setq captured p) t))) + (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))) + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig") + (pearl-delete-current-comment) + (should (string-match-p "from Linear and remove it here" captured))))))) + +(ert-deftest test-pearl-delete-comment-dirty-prompt-names-both () + "A locally edited own comment uses the discard-local-edits prompt naming both." + (test-pearl-del--in-org (test-pearl-del--doc "a different stored body") + (let ((captured nil)) + (cl-letf (((symbol-function 'pearl--delete-comment-async) + (lambda (_id cb) (funcall cb '(:success t)))) + ((symbol-function 'yes-or-no-p) (lambda (p) (setq captured p) t))) + (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))) + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig") + (pearl-delete-current-comment) + (should (string-match-p "Linear" captured)) + (should (string-match-p "discard" captured))))))) + +(ert-deftest test-pearl-delete-comment-missing-sha-takes-stronger-prompt () + "A comment with no stored hash (unknown provenance) takes the discard prompt." + (test-pearl-del--in-org (test-pearl-del--doc 'none) + (let ((captured nil)) + (cl-letf (((symbol-function 'pearl--delete-comment-async) + (lambda (_id cb) (funcall cb '(:success t)))) + ((symbol-function 'yes-or-no-p) (lambda (p) (setq captured p) t))) + (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))) + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig") + (pearl-delete-current-comment) + (should (string-match-p "discard" captured))))))) + +(ert-deftest test-pearl-delete-comment-killed-buffer-no-signal () + "If the buffer is killed before the delete callback fires, nothing signals." + (let ((buf (generate-new-buffer "pearl-del-killtest")) + (pearl--cache-viewer '(:id "u-me" :name "Me"))) + (unwind-protect + (progn + (with-current-buffer buf + (let ((org-element-use-cache nil)) + (insert (test-pearl-del--doc)) + (org-mode) + (test-pearl-del--goto "^\\*\\*\\*\\*\\* Craig"))) + ;; The delete stub kills the buffer, then reports success -- the + ;; success callback must not signal against the dead buffer. + (cl-letf (((symbol-function 'pearl--delete-comment-async) + (lambda (_id cb) (kill-buffer buf) (funcall cb '(:success t)))) + ((symbol-function 'yes-or-no-p) (lambda (_p) t))) + (with-current-buffer buf + ;; should complete without error + (should-not (pearl-delete-current-comment))))) + (when (buffer-live-p buf) (kill-buffer buf))))) + +(provide 'test-pearl-comment-deletion) +;;; test-pearl-comment-deletion.el ends here diff --git a/tests/test-pearl-keymap.el b/tests/test-pearl-keymap.el index 97d8440..260b6fa 100644 --- a/tests/test-pearl-keymap.el +++ b/tests/test-pearl-keymap.el @@ -66,9 +66,9 @@ Walks MAP looking for a labeled binding (\"label\" . COMMAND)." (should (keymapp (lookup-key pearl-prefix-map (kbd "n")))) (should (keymapp (lookup-key pearl-prefix-map (kbd "d"))))) -(ert-deftest test-pearl-prefix-map-no-delete-comment-yet () - "Delete-comment is not bound -- the command is a separate task." - (should-not (commandp (lookup-key pearl-prefix-map (kbd "d c"))))) +(ert-deftest test-pearl-prefix-map-delete-comment-bound () + "Delete-comment fills the d c slot under the delete prefix." + (should (eq 'pearl-delete-current-comment (lookup-key pearl-prefix-map (kbd "d c"))))) (ert-deftest test-pearl-prefix-map-not-bound-at-load () "Loading pearl does not bind the commands to any active key (opt-in)." |
