diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-25 07:33:16 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-25 07:33:16 -0500 |
| commit | 291abb5d810d9910e80991881674439174bc56cf (patch) | |
| tree | e7a2e289ae1f50682ca0587da76cb04b2fdc08f2 /pearl.el | |
| parent | 9b17dc89fc25417e207943dafe8bf09ac211ae32 (diff) | |
| download | pearl-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.el | 147 |
1 files changed, 100 insertions, 47 deletions
@@ -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)) |
