diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-24 16:58:49 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-24 16:58:49 -0500 |
| commit | 424d7048d5450131283f6bdb99822aa6bccd6b16 (patch) | |
| tree | 528a95468e5e762a0b4510fa5fc96fef5d0f3c99 /pearl.el | |
| parent | d03d5582197def92ad72e113815a3c4836da1330 (diff) | |
| download | pearl-424d7048d5450131283f6bdb99822aa6bccd6b16.tar.gz pearl-424d7048d5450131283f6bdb99822aa6bccd6b16.zip | |
feat: render recent comments in the bulk issue list
A populated list used to look like nothing had comments, because only a single-issue refresh fetched them. The bulk and Custom View queries now pull each issue's most recent comments and render them under the Comments heading, with a marker showing how many are shown against the total: 💬 5/18, or 💬 5/25+ once the count passes the cap.
Linear's API has no comment total (no commentCount on Issue, no totalCount on the connection), so I fetch one more than pearl-list-comments-count-cap newest-first: that gives an exact total up to the cap and a "+" beyond it, in a single round trip. pearl-list-comments-shown (default 5) caps how many render. pearl-fetch-comments-in-list (default on) turns the whole thing off to keep the fetch light. The single-issue refresh still shows the full thread, uncapped and unmarked.
The marker sits on the Comments heading rather than the issue title, so it stays out of the title-sync hash. I relaxed the append locator to match a marked heading, so adding a comment still finds the existing subtree instead of creating a second one. Verified live: the capped fragment is accepted and the markers render correctly.
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 126 |
1 files changed, 102 insertions, 24 deletions
@@ -843,8 +843,50 @@ is the already-extracted (normalized) list, so this stays pure." ;; raw nodes (the query fetches the full field superset); normalization happens ;; at the render boundary, not here. -(defconst pearl--issues-query - "query Issues($filter: IssueFilter, $first: Int!, $after: String, $orderBy: PaginationOrderBy) { +(defcustom pearl-fetch-comments-in-list t + "When non-nil, the bulk list and Custom View fetch each issue's recent comments. +A populated list then renders the latest few comments per issue with a +`💬 shown/total' marker on the Comments heading, instead of looking like +nothing has comments. Set to nil to keep the list fetch light (comments load +only on a single-issue refresh)." + :type 'boolean + :group 'pearl) + +(defcustom pearl-list-comments-shown 5 + "How many of an issue's most recent comments render in the bulk list. +The single-issue refresh always shows the full thread; this caps only the +list/view view. See `pearl-fetch-comments-in-list'." + :type 'integer + :group 'pearl) + +(defcustom pearl-list-comments-count-cap 25 + "Ceiling for the exact comment total shown in the bulk list marker. +The fetch pulls one more than this so the marker can show an exact total up to +the cap (`💬 5/18') and a `+' beyond it (`💬 5/25+'). A higher cap means an +exact count for busier issues at the cost of a larger fetch." + :type 'integer + :group 'pearl) + +(defconst pearl--comment-node-fields + "id body createdAt user { id name displayName } botActor { name } externalUser { name }" + "The comment node field selection shared by every query that pulls comments.") + +(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 +renderer can show an exact total up to the cap and a `+' beyond it. Empty when +`pearl-fetch-comments-in-list' is nil, so the list fetch stays light." + (if pearl-fetch-comments-in-list + (format "comments(first: %d, orderBy: createdAt) { nodes { %s } }" + (1+ pearl-list-comments-count-cap) + pearl--comment-node-fields) + "")) + +(defun pearl--issues-query () + "GraphQL query for a filtered, ordered page of issues. +Pulls each issue's recent comments (see `pearl--list-comments-fragment') so a +populated list renders them, not just the single-issue refresh." + (format "query Issues($filter: IssueFilter, $first: Int!, $after: String, $orderBy: PaginationOrderBy) { issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { nodes { id identifier title description priority url updatedAt @@ -854,14 +896,11 @@ is the already-extracted (normalized) list, so this stays pure." project { id name } labels { nodes { id name } } cycle { id number name } - comments { nodes { id body createdAt user { id name displayName } botActor { name } externalUser { name } } } + %s } pageInfo { hasNextPage endCursor } } -}" - "GraphQL query for a filtered, ordered page of issues. -Pulls comments per issue so a populated list renders them, not just the -single-issue refresh.") +}" (pearl--list-comments-fragment))) (defconst pearl--single-issue-query "query Issue($id: String!) { @@ -877,8 +916,8 @@ single-issue refresh.") } }" "GraphQL query for one issue by id. -Same field shape as `pearl--issues-query' (which now also pulls comments); -this is the single-issue refresh path.") +The single-issue refresh path: pulls the full comment thread uncapped (unlike +the bulk list, which caps via `pearl--list-comments-fragment').") (defun pearl--fetch-issue-async (issue-id callback) "Fetch the full issue node for ISSUE-ID, calling CALLBACK with the outcome. @@ -899,8 +938,11 @@ gone\" apart from \"the API call failed.\"" (funcall callback (or issue :missing))))) (lambda (_error _response _data) (funcall callback :error)))) -(defconst pearl--view-issues-query - "query ViewIssues($id: String!, $first: Int!, $after: String) { +(defun pearl--view-issues-query () + "GraphQL query running a Custom View's own filter server-side, by view id. +Pulls each issue's recent comments (see `pearl--list-comments-fragment') so a +view-populated list renders them." + (format "query ViewIssues($id: String!, $first: Int!, $after: String) { customView(id: $id) { issues(first: $first, after: $after) { nodes { @@ -911,14 +953,12 @@ gone\" apart from \"the API call failed.\"" project { id name } labels { nodes { id name } } cycle { id number name } - comments { nodes { id body createdAt user { id name displayName } botActor { name } externalUser { name } } } + %s } pageInfo { hasNextPage endCursor } } } -}" - "GraphQL query running a Custom View's own filter server-side, by view id. -Pulls comments per issue so a view-populated list renders them.") +}" (pearl--list-comments-fragment))) (defun pearl--query-view-async (view-id callback) "Run the Custom View VIEW-ID server-side, calling CALLBACK with a query-result. @@ -927,7 +967,7 @@ raw nodes (normalized at the render boundary), paged like the general fetch." (let ((page-fn (lambda (after page-cb) (pearl--graphql-request-async - pearl--view-issues-query + (pearl--view-issues-query) `(("id" . ,view-id) ("first" . 100) ,@(when after (list (cons "after" after)))) @@ -1012,7 +1052,7 @@ nodes; normalization happens at the render boundary." (let ((page-fn (lambda (after page-cb) (pearl--graphql-request-async - pearl--issues-query + (pearl--issues-query) `(,@(when filter (list (cons "filter" filter))) ("first" . 100) ,@(when after (list (cons "after" after))) @@ -1761,18 +1801,30 @@ A null author renders as `(unknown)'." ":END:\n" (if (string-empty-p body) "" (concat body "\n"))))) -(defun pearl--format-comments (comments) +(defun pearl--comment-count-marker (count-info) + "Render COUNT-INFO as a ` 💬 shown/total[+]' suffix, or the empty string. +COUNT-INFO is a plist (:shown N :total M :overflow BOOL) set on the bulk-list +path; nil (the single-issue thread) yields no marker." + (if count-info + (format " 💬 %d/%d%s" + (plist-get count-info :shown) + (plist-get count-info :total) + (if (plist-get count-info :overflow) "+" "")) + "")) + +(defun pearl--format-comments (comments &optional count-info) "Format COMMENTS (a list of normalized comment plists) as a Comments subtree. Comments render oldest-first under a level-3 `Comments' heading. Returns the empty string when COMMENTS is nil, so an issue with no comments renders no -subtree." +subtree. COUNT-INFO, when non-nil, adds a `💬 shown/total' marker to the +heading (the bulk list passes the issue's `:comment-count')." (if (null comments) "" (let ((sorted (sort (copy-sequence comments) (lambda (a b) (string< (or (plist-get a :created-at) "") (or (plist-get b :created-at) "")))))) - (concat "*** Comments\n" + (concat "*** Comments" (pearl--comment-count-marker count-info) "\n" (mapconcat #'pearl--format-comment sorted ""))))) (defun pearl--format-issue-as-org-entry (issue) @@ -1822,7 +1874,8 @@ identifier prefix -- so an unedited heading is a no-op on title sync)." (format ":LINEAR-TITLE-SHA256: %s\n" (secure-hash 'sha256 display-title)) ":END:\n" (if (string-empty-p body-org) "" (concat body-org "\n")) - (pearl--format-comments (plist-get issue :comments))))) + (pearl--format-comments (plist-get issue :comments) + (plist-get issue :comment-count))))) ;;; Description Sync-Back @@ -2365,7 +2418,8 @@ that subtree at the end of the issue when it does not exist yet." (let* ((issue-end (save-excursion (org-end-of-subtree t t) (point))) (comments-pos (save-excursion - (when (re-search-forward "^\\*+ Comments[ \t]*$" issue-end t) + ;; tolerate a trailing "💬 shown/total" marker on the heading + (when (re-search-forward "^\\*+ Comments\\(?:[ \t].*\\)?$" issue-end t) (match-beginning 0))))) (if comments-pos (progn @@ -2940,6 +2994,26 @@ filter; SAVE-NAME, when given, persists it via `pearl--save-query'." (pearl--build-issue-filter filter-plist) (lambda (result) (pearl--render-query-result result source))))) +(defun pearl--cap-issue-list-comments (issue) + "Cap a bulk-list ISSUE's fetched comments and record a `:comment-count' marker. +The list/view fetch pulls the newest `pearl-list-comments-count-cap'+1 comments +newest-first; keep the newest `pearl-list-comments-shown' for display (the +renderer re-sorts those oldest-first) and set `:comment-count' to (:shown N +:total M :overflow BOOL), where the total is exact up to the cap and overflows +past it. An issue with no fetched comments is returned unchanged, so it renders +no Comments subtree." + (let ((comments (plist-get issue :comments))) + (if (null comments) + issue + (let* ((n (length comments)) + (cap pearl-list-comments-count-cap) + (overflow (> n cap)) + (total (if overflow cap n)) + (shown (seq-take comments pearl-list-comments-shown))) + (plist-put + (plist-put (copy-sequence issue) :comments shown) + :comment-count (list :shown (length shown) :total total :overflow overflow)))))) + (defun pearl--render-query-result (result source) "Render a query RESULT into the active file, tagged with SOURCE. Normalizes the raw nodes, writes them via `pearl--update-org-from-issues' @@ -2949,7 +3023,9 @@ refresh." (pcase (pearl--query-result-status result) ('ok (let ((issues (pearl--sort-issues - (mapcar #'pearl--normalize-issue + (mapcar (lambda (n) + (pearl--cap-issue-list-comments + (pearl--normalize-issue n))) (pearl--query-result-issues result)) (plist-get source :sort) (plist-get source :order))) @@ -3087,7 +3163,9 @@ of the replace path." (pcase (pearl--query-result-status result) ('ok (let* ((issues (pearl--sort-issues - (mapcar #'pearl--normalize-issue + (mapcar (lambda (n) + (pearl--cap-issue-list-comments + (pearl--normalize-issue n))) (pearl--query-result-issues result)) (plist-get source :sort) (plist-get source :order))) |
