aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 05:00:48 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 05:00:48 -0500
commit9b17dc89fc25417e207943dafe8bf09ac211ae32 (patch)
treec92779db74b0a4cccc6e38d7307c781aa62e9356
parent9f15d1823f388910f695da25ad5878312cc80330 (diff)
downloadpearl-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.org5
-rw-r--r--pearl.el76
-rw-r--r--tests/test-pearl-comment-deletion.el237
-rw-r--r--tests/test-pearl-keymap.el6
4 files changed, 318 insertions, 6 deletions
diff --git a/README.org b/README.org
index b2ffbbe..f10233b 100644
--- a/README.org
+++ b/README.org
@@ -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
diff --git a/pearl.el b/pearl.el
index fc7667e..2ec5497 100644
--- a/pearl.el
+++ b/pearl.el
@@ -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)."