diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-05 01:06:18 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-05 01:06:18 -0500 |
| commit | 3f491e73cdbdc03d652bce81fdfc68eabb313213 (patch) | |
| tree | 772f0e109ac0eda3a789f0d83bb8737234dd0ed9 /pearl.el | |
| parent | 951abea0b2ed07d71676cd4d10ff7c6ca50d1390 (diff) | |
| download | pearl-3f491e73cdbdc03d652bce81fdfc68eabb313213.tar.gz pearl-3f491e73cdbdc03d652bce81fdfc68eabb313213.zip | |
feat(comments): fetch an issue's full comment thread
pearl-fetch-all-comments pages the whole comment connection for the issue at point and replaces its Comments subtree with the complete set. It's bound to C-; L f c and in the transient. The bulk list and view fetch showed only the newest pearl-list-comments-shown comments with an N/M+ overflow marker, so anything past the cap couldn't surface in the buffer.
The replacement is surgical: pearl--replace-comments-subtree swaps only the Comments subtree, so the issue title, drawer, and description body keep any unsaved edits, and the new heading carries an exact N/N count. It refuses when the issue has unpushed comment edits (reusing the save model's dirty scanner) so the fetch can't clobber an unsent change.
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 124 |
1 files changed, 122 insertions, 2 deletions
@@ -1552,6 +1552,19 @@ exact count for busier issues at the cost of a larger fetch." "id body createdAt user { id name displayName } botActor { name } externalUser { name }" "The comment node field selection shared by every query that pulls comments.") +(defconst pearl--all-comments-query + (format "query AllComments($id: String!, $after: String) { + issue(id: $id) { + comments(first: 100, after: $after, orderBy: createdAt) { + nodes { %s } + pageInfo { hasNextPage endCursor } + } + } +}" pearl--comment-node-fields) + "Query for one issue's full comment thread; paged with first/after. +Unlike `pearl--list-comments-fragment' (which caps the bulk list), this pulls +every comment so `pearl-fetch-all-comments' can show the whole thread.") + (defun pearl--list-comments-fragment () "Return the GraphQL comments fragment for the bulk/view list, or empty string. Fetches one more than `pearl-list-comments-count-cap', newest first, so the @@ -3618,6 +3631,110 @@ create callback fires with another buffer current (the compose path)." (pearl--surface-buffer buf))) (message "Added comment to %s" issue-id))))) +(defun pearl--fetch-all-comments-async (issue-id callback) + "Fetch every comment on ISSUE-ID, calling CALLBACK with the normalized list. +Pages the comment connection via first/after. On a transport/GraphQL error +CALLBACK receives the symbol `error' (already logged) so the caller can leave +the buffer untouched rather than mistake a failure for an empty thread." + (let (acc) + (cl-labels + ((collect (data) + (let* ((issue (cdr (assoc 'issue (cdr (assoc 'data data))))) + (conn (and issue (cdr (assoc 'comments issue)))) + (nodes (append (cdr (assoc 'nodes conn)) nil)) + (page (cdr (assoc 'pageInfo conn)))) + (dolist (n nodes) + (push (pearl--normalize-comment n) acc)) + (if (eq (cdr (assoc 'hasNextPage page)) t) + (pearl--graphql-request-async + pearl--all-comments-query + (list (cons "id" issue-id) + (cons "after" (cdr (assoc 'endCursor page)))) + #'collect + (lambda (&rest _) (funcall callback 'error))) + (funcall callback (nreverse acc)))))) + (pearl--graphql-request-async + pearl--all-comments-query + (list (cons "id" issue-id)) + #'collect + (lambda (&rest _) (funcall callback 'error)))))) + +(defun pearl--replace-comments-subtree (comments) + "Replace the issue-at-point's Comments subtree with COMMENTS (the full thread). +Climbs to the issue heading, removes its existing Comments subtree if any, and +inserts a freshly formatted one carrying an exact `N/N' count (no `+'). Empty +COMMENTS clears the subtree. Only the Comments subtree is touched, so the issue +title, drawer, and description body -- including unsaved description edits -- +survive." + (save-excursion + (pearl--goto-issue-heading-or-error) + (let* ((level (org-current-level)) + (issue-end (save-excursion (org-end-of-subtree t t) (point))) + (comments-pos + (save-excursion + (when (re-search-forward + "^\\*+ \\(?:💬 \\)?Comments\\(?:[ \t].*\\)?$" issue-end t) + (match-beginning 0)))) + (n (length comments)) + (count-info (and (> n 0) (list :shown n :total n :overflow nil))) + (rendered (pearl--format-comments comments count-info level))) + ;; The Comments subtree is the issue's last child, so its region runs from + ;; the Comments heading to the end of the issue subtree. + (if comments-pos + (progn (goto-char comments-pos) + (delete-region comments-pos issue-end)) + (goto-char issue-end)) + (let ((start (point))) + (insert rendered) + (pearl--hide-drawers-in-region start (point)))))) + +(defun pearl--unpushed-comment-edits-p () + "Non-nil when the issue at point has comments with unpushed local edits. +The guard for `pearl-fetch-all-comments': replacing the thread would discard a +comment whose body was edited but not yet saved, so the fetch refuses while any +exist." + (save-excursion + (pearl--goto-issue-heading-or-error) + (consp (pearl--changed-comment-candidates)))) + +;;;###autoload +(defun pearl-fetch-all-comments () + "Fetch every comment on the Linear issue at point and render the full thread. +The bulk/view fetch shows only the newest `pearl-list-comments-shown' comments +\(with a ` N/M+' marker when more exist); this pages the issue's whole comment +connection and replaces its Comments subtree with the complete set. Works from +anywhere inside the issue subtree. Refuses when the issue has unpushed local +comment edits, so the fetch never clobbers an unsent change -- save or discard +those first." + (interactive) + (pearl--require-account-context) + (save-excursion + (pearl--goto-issue-heading-or-error) + (let ((issue-id (org-entry-get nil "LINEAR-ID")) + (ident (or (org-entry-get nil "LINEAR-IDENTIFIER") "this issue")) + (marker (point-marker)) + (buf (current-buffer))) + (when (pearl--unpushed-comment-edits-p) + (user-error "This issue has unpushed comment edits; save or discard them before fetching the full thread")) + (pearl--progress "Fetching all comments for %s..." ident) + (pearl--fetch-all-comments-async + issue-id + (lambda (comments) + (cond + ((eq comments 'error) + (message "Pearl: couldn't fetch comments for %s" ident)) + ((not (buffer-live-p buf)) nil) + (t + (with-current-buffer buf + (save-excursion + (goto-char marker) + (pearl--replace-comments-subtree comments)) + (pearl-highlight-comments)) + (pearl--surface-buffer buf) + (message "Fetched all %d comment%s for %s" + (length comments) + (if (= 1 (length comments)) "" "s") ident)))))))) + ;;;###autoload (defun pearl-create-comment (&optional body) "Add a comment to the Linear issue at point. @@ -7603,6 +7720,7 @@ body stay." ["Buffer" ("g" "refresh view" pearl-refresh-current-view) ("r" "refresh issue" pearl-refresh-current-issue) + ("F" "all comments for issue" pearl-fetch-all-comments) ("b" "open view in Linear" pearl-open-current-view-in-linear) ("o" "open issue in browser" pearl-open-current-issue) ("y" "copy issue URL" pearl-copy-issue-url)] @@ -7642,11 +7760,13 @@ body stay." (define-key map "o" (cons "my open issues" #'pearl-list-issues)) (define-key map "p" (cons "by project" #'pearl-list-issues-by-project)) (define-key map "f" (cons "build a filter" #'pearl-list-issues-filtered)) + (define-key map "c" (cons "all comments for issue" #'pearl-fetch-all-comments)) map) "Pearl issue-fetch commands; a sub-keymap of `pearl-prefix-map'. Issue sources only -- pick a source, your open issues, a project, or an -ad-hoc filter. View-movement commands (run / publish / save-locally) live -in `pearl-views-map' so this group stays one concern.") +ad-hoc filter -- plus `c' to pull the full comment thread of the issue at +point. View-movement commands (run / publish / save-locally) live in +`pearl-views-map' so this group stays one concern.") (defvar pearl-views-map (let ((map (make-sparse-keymap))) |
