aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 07:33:16 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 07:33:16 -0500
commit291abb5d810d9910e80991881674439174bc56cf (patch)
treee7a2e289ae1f50682ca0587da76cb04b2fdc08f2 /pearl.el
parent9b17dc89fc25417e207943dafe8bf09ac211ae32 (diff)
downloadpearl-291abb5d810d9910e80991881674439174bc56cf.tar.gz
pearl-291abb5d810d9910e80991881674439174bc56cf.zip
fix: seven quick bugs from the 2026-05-25 code review
A batch of small, independent fixes a code-review pass turned up, each its own :bug:quick:solo: todo. They share no code path beyond pearl.el, so the body lists them. Single-issue refresh false-stash: pearl-refresh-current-issue decided whether to stash "local edits" by hashing org->md against LINEAR-DESC-SHA256, the lossy markdown round-trip. A clean issue whose description has lossy markdown (a # heading, single-asterisk italics) looked dirty and got stashed before every refresh. It now uses pearl--subtree-dirty-p, the Org-hash-first check the merge refresh already uses. Comment outcome parent id: pearl--save-comment-field read :issue-id from the comment heading, which carries LINEAR-COMMENT-ID, not LINEAR-ID, so comment outcomes got :issue-id nil against the contract. A new pearl--issue-id-at-point climbs to the enclosing issue. save-all viewer-unavailable count: when the viewer lookup failed, save-all passed a nil viewer id and the prompt counted every dirty comment as read-only, disagreeing with the actual skipped/viewer-unavailable outcome. The counts and prompt now carry an explicit viewer-unavailable tally. Request-counter leak: pearl--graphql-request-async incremented pearl--active-requests before pearl--headers, which signals when the key is unset, so neither callback ran to decrement it. I build the headers before the increment. Issue commands from comment subtrees: save-issue, refresh, compose-description, open-issue, and the field setters read LINEAR-ID from the nearest heading, so running them from inside a comment hit the comment heading and rejected with "Not on a Linear issue heading". A new pearl--goto-issue-heading-or-error climbs to the issue heading; the commands use it. Project selector on no projects: pearl-select-project ran (string= selected "None") even when a team had no projects and selected was nil. I guard the nil case. (The reported wrong-type crash doesn't reproduce here, since string= treats nil as the symbol "nil", but the guard makes the optional-no-projects path explicit.) Query filter validation: pearl-run-saved-query, pearl-list-issues-filtered, and pearl-list-issues compiled their authoring plist without calling pearl--validate-issue-filter, so an unknown key in a saved query was silently ignored. They now validate at the command boundary, so typos surface as a clear user-error.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el147
1 files changed, 100 insertions, 47 deletions
diff --git a/pearl.el b/pearl.el
index 2ec5497..8adca82 100644
--- a/pearl.el
+++ b/pearl.el
@@ -367,8 +367,6 @@ If SUCCESS-FN or ERROR-FN are not provided, default handlers will be used."
(when variables
(pearl--log "Variables: %s" (prin1-to-string variables)))
- (setq pearl--active-requests (1+ pearl--active-requests))
-
(unless success-fn
(setq success-fn (lambda (data)
(pearl--log "Request completed: %s" (prin1-to-string data)))))
@@ -379,13 +377,18 @@ If SUCCESS-FN or ERROR-FN are not provided, default handlers will be used."
(pearl--log "Error response: %s" (prin1-to-string data)))))
(let ((request-data (json-encode `(("query" . ,query)
- ,@(when variables `(("variables" . ,variables)))))))
+ ,@(when variables `(("variables" . ,variables))))))
+ ;; Build headers before bumping the counter: `pearl--headers' signals
+ ;; when the API key is unset, and that must happen before the increment
+ ;; or no callback ever runs to decrement it (leaking the count).
+ (headers (pearl--headers)))
(pearl--log "Request payload: %s" request-data)
+ (setq pearl--active-requests (1+ pearl--active-requests))
(request
pearl-graphql-url
:type "POST"
- :headers (pearl--headers)
+ :headers headers
:data request-data
:parser 'json-read
:success (cl-function
@@ -1508,7 +1511,9 @@ Gives immediate feedback and performs the API update in the background."
(selected (when project-names
(completing-read "Select project (optional): "
(cons "None" project-names) nil t nil nil "None"))))
- (unless (string= selected "None")
+ ;; Guard the nil case explicitly: a team with no projects (or a failed
+ ;; fetch) leaves SELECTED nil, so only consult the choice when one was made.
+ (when (and selected (not (string= selected "None")))
(cdr (assoc selected project-names)))))
;;; Other Issue Attributes
@@ -2299,6 +2304,29 @@ Callers wrap this in `save-excursion' when they must not move point."
(unless (ignore-errors (org-back-to-heading t) t)
(user-error "%s" (or message "Not on a Linear issue heading"))))
+(defun pearl--issue-id-at-point ()
+ "Return the `LINEAR-ID' of the issue heading enclosing point, or nil.
+Climbs ancestors: from inside a comment subtree (a heading carrying
+`LINEAR-COMMENT-ID' but no `LINEAR-ID') it walks up to the issue heading. Does
+not move point or signal."
+ (save-excursion
+ (when (ignore-errors (org-back-to-heading t) t)
+ (while (and (not (org-entry-get nil "LINEAR-ID"))
+ (org-up-heading-safe)))
+ (org-entry-get nil "LINEAR-ID"))))
+
+(defun pearl--goto-issue-heading-or-error ()
+ "Move point to the enclosing issue heading (one carrying `LINEAR-ID'), or error.
+The guard for issue-scoped commands that must work from anywhere inside an
+issue subtree, including from a comment heading or body: it climbs out of the
+comment to the issue heading. Signals a `user-error' when no ancestor heading
+carries `LINEAR-ID'."
+ (pearl--goto-heading-or-error)
+ (while (and (not (org-entry-get nil "LINEAR-ID"))
+ (org-up-heading-safe)))
+ (unless (org-entry-get nil "LINEAR-ID")
+ (user-error "Not on a Linear issue heading")))
+
;;;###autoload
(defun pearl-sync-current-issue ()
"Push the description edited in the Org body of the issue at point to Linear.
@@ -2366,18 +2394,19 @@ buffer) so the refresh can't silently lose them (decision 4), then the refresh
proceeds and the subtree is replaced with Linear's version."
(interactive)
(save-excursion
- (pearl--goto-heading-or-error)
+ (pearl--goto-issue-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
- (stored (org-entry-get nil "LINEAR-DESC-SHA256"))
(marker (point-marker)))
- (unless issue-id
- (user-error "Not on a Linear issue heading"))
- (let ((local-md (pearl--org-to-md (pearl--issue-body-at-point))))
- ;; Stash an unpushed edit before the overwrite rather than refusing,
- ;; so an explicit single-issue refresh always proceeds without data loss.
- (unless (string= (secure-hash 'sha256 local-md) (or stored ""))
+ (progn
+ ;; Stash an unpushed edit before the overwrite rather than refusing, so
+ ;; an explicit single-issue refresh always proceeds without data loss.
+ ;; Use the Org-hash-first dirty check (`pearl--subtree-dirty-p') so a
+ ;; clean description whose markdown is lossy under org->md isn't falsely
+ ;; flagged dirty and stashed -- the same fix the merge refresh uses.
+ (when (pearl--subtree-dirty-p)
(pearl--stash-conflict-text
- (format "%s description (pre-refresh)" issue-id) local-md))
+ (format "%s description (pre-refresh)" issue-id)
+ (pearl--org-to-md (pearl--issue-body-at-point))))
(pearl--progress "Refreshing %s from Linear..." issue-id)
(pearl--fetch-issue-async
issue-id
@@ -2502,11 +2531,9 @@ cancels. An alternative to editing the description inline for anyone who wants
a dedicated buffer. Works from anywhere inside an issue subtree."
(interactive)
(save-excursion
- (pearl--goto-heading-or-error)
+ (pearl--goto-issue-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
(marker (point-marker)))
- (unless issue-id
- (user-error "Not on a Linear issue heading"))
(pearl--compose-in-buffer
(format "description for %s" issue-id)
pearl--compose-description-instructions
@@ -2523,7 +2550,7 @@ Reads the `LINEAR-URL' property of the enclosing issue heading and hands it to
`browse-url'. Works from anywhere inside the issue subtree."
(interactive)
(let ((url (save-excursion
- (pearl--goto-heading-or-error)
+ (pearl--goto-issue-heading-or-error)
(org-entry-get nil "LINEAR-URL"))))
(unless (and url (not (string-empty-p url)))
(user-error "No LINEAR-URL on the issue at point"))
@@ -2646,12 +2673,10 @@ anywhere inside an issue subtree."
(interactive
(list (completing-read "Priority: " pearl--priority-choices nil t)))
(save-excursion
- (pearl--goto-heading-or-error)
+ (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 issue-id
- (user-error "Not on a Linear issue heading"))
(unless priority-num
(user-error "Unknown priority: %s" priority-name))
(pearl--push-issue-field
@@ -2689,7 +2714,7 @@ success. Works from anywhere inside an issue subtree."
(and team-id (pearl--team-states team-id)))
nil t))))
(save-excursion
- (pearl--goto-heading-or-error)
+ (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))
@@ -2726,7 +2751,7 @@ anywhere inside an issue subtree."
(pearl--team-collection-names 'members team-id)
nil t))))
(save-excursion
- (pearl--goto-heading-or-error)
+ (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))
@@ -2759,7 +2784,7 @@ issue subtree."
"Labels (comma-separated, empty to clear): "
(pearl--team-collection-names 'labels team-id)))))
(save-excursion
- (pearl--goto-heading-or-error)
+ (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))
@@ -2967,6 +2992,10 @@ active file with the query recorded as the source."
(order (plist-get spec :order))
(source (list :type 'filter :name name :filter filter-plist
:sort sort :order order)))
+ ;; Validate the saved query's authoring plist at the command boundary so
+ ;; a typo'd key or bad value surfaces as a clear user-error instead of
+ ;; being silently dropped or failing deeper in the compiler.
+ (pearl--validate-issue-filter filter-plist)
(pearl--progress "Running saved query %s..." name)
(pearl--query-issues-async
(pearl--build-issue-filter filter-plist)
@@ -3044,6 +3073,9 @@ filter; SAVE-NAME, when given, persists it via `pearl--save-query'."
(list (pearl--read-filter-interactively)
(when (y-or-n-p "Save as a local query? ")
(read-string "Query name: "))))
+ ;; Validate before saving or running, so a bad filter never gets persisted
+ ;; or run -- the command boundary is where the clear error belongs.
+ (pearl--validate-issue-filter filter-plist)
(when (and save-name (not (string-empty-p save-name)))
(pearl--save-query save-name filter-plist))
(let ((source (list :type 'filter
@@ -3366,7 +3398,9 @@ only the viewer's own comments are saved through the gate."
(let* ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID"))
(author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID"))
(spec (list
- :issue-id (org-entry-get nil "LINEAR-ID")
+ ;; Comment headings carry LINEAR-COMMENT-ID, not LINEAR-ID, so
+ ;; climb to the enclosing issue for the outcome's :issue-id.
+ :issue-id (pearl--issue-id-at-point)
:identifier comment-id :field 'comment :comment-id comment-id
:marker marker :prop "LINEAR-COMMENT-SHA256"
:local (pearl--org-to-md (pearl--issue-body-at-point))
@@ -3479,9 +3513,7 @@ is saved sequentially through its conflict gate and a single summary is
reported. A clean issue does no network at all."
(interactive)
(save-excursion
- (pearl--goto-heading-or-error)
- (unless (org-entry-get nil "LINEAR-ID")
- (user-error "Not on a Linear issue heading"))
+ (pearl--goto-issue-heading-or-error)
(let* ((marker (point-marker))
(buffer (current-buffer))
(dirty (pearl--issue-dirty-fields)))
@@ -3513,22 +3545,29 @@ wait for the next save (each field's content is still re-read at push)."
(push (cons marker dirty) result))))
(nreverse result)))
-(defun pearl--save-all-counts (scan viewer-id)
+(defun pearl--save-all-counts (scan viewer-id &optional viewer-failed)
"Tally the dirty fields in SCAN, classifying comments with VIEWER-ID.
-Returns a plist (:issues :titles :descriptions :own-comments
-:read-only-comments)."
- (let ((issues 0) (titles 0) (descriptions 0) (own 0) (read-only 0))
+When VIEWER-FAILED is non-nil (the viewer lookup ran and failed), every comment
+candidate counts as `:viewer-unavailable' rather than being classified as own /
+read-only -- a nil VIEWER-ID then means \"couldn't tell\", not \"none are
+yours\". Returns a plist (:issues :titles :descriptions :own-comments
+:read-only-comments :viewer-unavailable)."
+ (let ((issues 0) (titles 0) (descriptions 0) (own 0) (read-only 0)
+ (viewer-unavailable 0))
(dolist (cell scan)
(let ((dirty (cdr cell)))
(setq issues (1+ issues))
(when (plist-get dirty :title) (setq titles (1+ titles)))
(when (plist-get dirty :description) (setq descriptions (1+ descriptions)))
- (let ((split (pearl--classify-comment-candidates
- (plist-get dirty :comment-candidates) viewer-id)))
- (setq own (+ own (length (plist-get split :own))))
- (setq read-only (+ read-only (length (plist-get split :read-only)))))))
+ (let ((cands (plist-get dirty :comment-candidates)))
+ (if viewer-failed
+ (setq viewer-unavailable (+ viewer-unavailable (length cands)))
+ (let ((split (pearl--classify-comment-candidates cands viewer-id)))
+ (setq own (+ own (length (plist-get split :own))))
+ (setq read-only (+ read-only (length (plist-get split :read-only)))))))))
(list :issues issues :titles titles :descriptions descriptions
- :own-comments own :read-only-comments read-only)))
+ :own-comments own :read-only-comments read-only
+ :viewer-unavailable viewer-unavailable)))
(defun pearl--save-all-prompt (counts)
"Return the one-time confirmation prompt string for the dirty COUNTS plist."
@@ -3536,30 +3575,39 @@ Returns a plist (:issues :titles :descriptions :own-comments
(descriptions (plist-get counts :descriptions))
(own (plist-get counts :own-comments))
(read-only (plist-get counts :read-only-comments))
+ (viewer-unavailable (or (plist-get counts :viewer-unavailable) 0))
(fields (+ titles descriptions own))
(issues (plist-get counts :issues))
- parts)
+ parts skips)
(when (> titles 0)
(push (format "%d title%s" titles (if (= titles 1) "" "s")) parts))
(when (> descriptions 0)
(push (format "%d description%s" descriptions (if (= descriptions 1) "" "s")) parts))
(when (> own 0)
(push (format "%d comment%s" own (if (= own 1) "" "s")) parts))
+ (when (> read-only 0)
+ (push (format "%d read-only comment%s" read-only (if (= read-only 1) "" "s")) skips))
+ (when (> viewer-unavailable 0)
+ (push (format "%d comment%s (viewer unavailable)"
+ viewer-unavailable (if (= viewer-unavailable 1) "" "s"))
+ skips))
(format "Save %d field%s across %d issue%s? (%s%s) "
fields (if (= fields 1) "" "s")
issues (if (= issues 1) "" "s")
(if parts (string-join (nreverse parts) ", ") "0 fields")
- (if (> read-only 0)
- (format "; %d read-only comment%s skipped"
- read-only (if (= read-only 1) "" "s"))
+ (if skips
+ (concat "; " (string-join (nreverse skips) ", ") " skipped")
""))))
-(defun pearl--save-all-run (scan buffer viewer-id)
+(defun pearl--save-all-run (scan buffer viewer-id &optional viewer-failed)
"Confirm SCAN once, then save all its dirty fields and report.
-VIEWER-ID gates comment ownership; BUFFER is surfaced if anything pushed.
-Declining the prompt mutates nothing. The queue continues past a per-field
-conflict, so one conflicted ticket does not stop the rest."
- (if (not (y-or-n-p (pearl--save-all-prompt (pearl--save-all-counts scan viewer-id))))
+VIEWER-ID gates comment ownership; VIEWER-FAILED marks a failed viewer lookup so
+the prompt counts comments as viewer-unavailable rather than read-only. BUFFER
+is surfaced if anything pushed. Declining the prompt mutates nothing. The
+queue continues past a per-field conflict, so one conflicted ticket does not
+stop the rest."
+ (if (not (y-or-n-p (pearl--save-all-prompt
+ (pearl--save-all-counts scan viewer-id viewer-failed))))
(message "save-all cancelled; nothing saved")
(let (thunks)
(dolist (cell scan)
@@ -3591,7 +3639,11 @@ reported. A clean file does nothing."
(if (and has-comments (not pearl--cache-viewer))
(pearl--viewer-async
(lambda (viewer)
- (pearl--save-all-run scan buffer (and viewer (plist-get viewer :id)))))
+ ;; A nil viewer here means the lookup failed (we only do it when
+ ;; comments exist), so flag it -- those comments are
+ ;; viewer-unavailable, not read-only.
+ (pearl--save-all-run scan buffer (and viewer (plist-get viewer :id))
+ (null viewer))))
(pearl--save-all-run scan buffer
(and pearl--cache-viewer
(plist-get pearl--cache-viewer :id))))))))
@@ -3770,6 +3822,7 @@ how each issue's state renders as a TODO keyword."
(pearl--progress "Fetching issues from Linear...")
(let* ((authoring `(:assignee :me :open t
,@(when project-id (list :project project-id))))
+ (_ (pearl--validate-issue-filter authoring))
(source (list :type 'filter
:name (if project-id "My open issues in project" "My open issues")
:filter authoring))