aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 16:58:49 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 16:58:49 -0500
commit424d7048d5450131283f6bdb99822aa6bccd6b16 (patch)
tree528a95468e5e762a0b4510fa5fc96fef5d0f3c99
parentd03d5582197def92ad72e113815a3c4836da1330 (diff)
downloadpearl-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.
-rw-r--r--pearl.el126
-rw-r--r--tests/test-pearl-list-comments.el142
-rw-r--r--tests/test-pearl-query.el12
-rw-r--r--tests/test-pearl-views.el7
4 files changed, 259 insertions, 28 deletions
diff --git a/pearl.el b/pearl.el
index d0f21f4..b939ab3 100644
--- a/pearl.el
+++ b/pearl.el
@@ -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)))
diff --git a/tests/test-pearl-list-comments.el b/tests/test-pearl-list-comments.el
new file mode 100644
index 0000000..630c712
--- /dev/null
+++ b/tests/test-pearl-list-comments.el
@@ -0,0 +1,142 @@
+;;; test-pearl-list-comments.el --- Tests for comments in the bulk list -*- 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 rendering comments in the bulk issue list: the per-issue cap
+;; (`pearl--cap-issue-list-comments'), the `💬 shown/total' marker
+;; (`pearl--comment-count-marker', the Comments heading), and the way the
+;; append locator tolerates the marked heading so a later add-comment finds the
+;; existing subtree instead of creating a second one.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+(defun test-pearl--comments (n)
+ "Return N comment plists, newest-first (createdAt descending), as the fetch returns."
+ (let (out)
+ (dotimes (i n)
+ ;; i=0 is the newest; pad the index so string< orders correctly
+ (push (list :id (format "c%02d" (- n i))
+ :author "A"
+ :created-at (format "2026-05-%02dT00:00:00.000Z" (- n i))
+ :body (format "comment %d" (- n i)))
+ out))
+ (nreverse out)))
+
+;;; --cap-issue-list-comments
+
+(ert-deftest test-pearl-cap-comments-under-shown ()
+ "Fewer comments than the shown cap keeps them all with an exact total."
+ (let ((pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ (let ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 3)))))
+ (should (= 3 (length (plist-get out :comments))))
+ (should (equal '(:shown 3 :total 3 :overflow nil)
+ (plist-get out :comment-count))))))
+
+(ert-deftest test-pearl-cap-comments-over-shown-under-cap ()
+ "More than the shown cap but within the count cap: show 5, total exact."
+ (let ((pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ (let ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 18)))))
+ (should (= 5 (length (plist-get out :comments))))
+ (should (equal '(:shown 5 :total 18 :overflow nil)
+ (plist-get out :comment-count))))))
+
+(ert-deftest test-pearl-cap-comments-over-count-cap-overflows ()
+ "More than the count cap: total pins to the cap and overflows."
+ (let ((pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ ;; the fetch pulls cap+1 = 26 to detect overflow
+ (let ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 26)))))
+ (should (= 5 (length (plist-get out :comments))))
+ (should (equal '(:shown 5 :total 25 :overflow t)
+ (plist-get out :comment-count))))))
+
+(ert-deftest test-pearl-cap-comments-none-unchanged ()
+ "An issue with no fetched comments is returned untouched, with no marker."
+ (let ((issue (list :id "u" :comments nil)))
+ (let ((out (pearl--cap-issue-list-comments issue)))
+ (should-not (plist-get out :comments))
+ (should-not (plist-get out :comment-count)))))
+
+(ert-deftest test-pearl-cap-comments-keeps-the-newest ()
+ "The cap keeps the newest `shown' comments (input arrives newest-first)."
+ (let ((pearl-list-comments-shown 3) (pearl-list-comments-count-cap 25))
+ (let* ((out (pearl--cap-issue-list-comments
+ (list :id "u" :comments (test-pearl--comments 7))))
+ (ids (mapcar (lambda (c) (plist-get c :id)) (plist-get out :comments))))
+ ;; newest three are c07, c06, c05 (the head of the newest-first list)
+ (should (equal '("c07" "c06" "c05") ids)))))
+
+;;; --comment-count-marker
+
+(ert-deftest test-pearl-comment-count-marker-forms ()
+ "The marker renders shown/total, with a `+' past the cap and nothing for nil."
+ (should (string= " 💬 5/18" (pearl--comment-count-marker '(:shown 5 :total 18 :overflow nil))))
+ (should (string= " 💬 5/25+" (pearl--comment-count-marker '(:shown 5 :total 25 :overflow t))))
+ (should (string= " 💬 3/3" (pearl--comment-count-marker '(:shown 3 :total 3 :overflow nil))))
+ (should (string= "" (pearl--comment-count-marker nil))))
+
+;;; --format-comments with a marker
+
+(ert-deftest test-pearl-format-comments-renders-marker-and-oldest-first ()
+ "With count-info, the Comments heading carries the marker; bodies stay oldest-first."
+ (let* ((comments (test-pearl--comments 5)) ; newest-first as fetched
+ (out (pearl--format-comments comments '(:shown 5 :total 12 :overflow nil))))
+ (should (string-match-p "^\\*\\*\\* Comments 💬 5/12$" out))
+ ;; oldest (comment 1) renders before newest (comment 5)
+ (should (< (string-match "comment 1\\b" out) (string-match "comment 5\\b" out)))))
+
+(ert-deftest test-pearl-format-comments-no-marker-without-count ()
+ "Without count-info (the single-issue thread), the heading has no marker."
+ (let ((out (pearl--format-comments (test-pearl--comments 2))))
+ (should (string-match-p "^\\*\\*\\* Comments$" out))))
+
+;;; the marked heading is still found by the append locator
+
+(ert-deftest test-pearl-append-finds-marked-comments-heading ()
+ "Adding a comment to an issue whose Comments heading carries a marker reuses it."
+ (let ((pearl-state-to-todo-mapping '(("Todo" . "TODO")))
+ (pearl-list-comments-shown 5) (pearl-list-comments-count-cap 25))
+ (let ((entry (pearl--format-issue-as-org-entry
+ (pearl--cap-issue-list-comments
+ (list :id "a" :identifier "ENG-1" :title "issue"
+ :priority 3 :state '(:name "Todo")
+ :comments (test-pearl--comments 8))))))
+ (with-temp-buffer
+ (insert entry)
+ (org-mode)
+ (goto-char (point-min))
+ ;; the rendered Comments heading carries the marker
+ (should (re-search-forward "^\\*\\*\\* Comments 💬 5/8$" nil t))
+ (goto-char (point-min))
+ (re-search-forward "issue")
+ (pearl--append-comment-to-issue
+ '(:id "cnew" :author "Z" :created-at "2026-06-01T00:00:00.000Z" :body "appended"))
+ ;; exactly one Comments heading, and the new comment landed under it
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\*\\* Comments" nil t))
+ (should-not (re-search-forward "^\\*\\*\\* Comments" nil t))
+ (should (string-match-p "appended" (buffer-string)))))))
+
+(provide 'test-pearl-list-comments)
+;;; test-pearl-list-comments.el ends here
diff --git a/tests/test-pearl-query.el b/tests/test-pearl-query.el
index b5d2816..697dd9a 100644
--- a/tests/test-pearl-query.el
+++ b/tests/test-pearl-query.el
@@ -144,8 +144,16 @@
;;; the bulk query fetches comments so the list can render them
(ert-deftest test-pearl-issues-query-requests-comments ()
- "The bulk issues query selects comments, so a populated list shows them."
- (should (string-match-p "comments[[:space:]]*{[[:space:]]*nodes" pearl--issues-query)))
+ "The bulk issues query selects capped, newest-first comments by default."
+ (let ((pearl-fetch-comments-in-list t)
+ (pearl-list-comments-count-cap 25))
+ (should (string-match-p "comments(first: 26, orderBy: createdAt)[[:space:]]*{[[:space:]]*nodes"
+ (pearl--issues-query)))))
+
+(ert-deftest test-pearl-issues-query-omits-comments-when-disabled ()
+ "With list comments disabled, the bulk query carries no comments selection."
+ (let ((pearl-fetch-comments-in-list nil))
+ (should-not (string-match-p "comments" (pearl--issues-query)))))
;;; malformed remote page shapes
diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el
index 2a5d6bd..2f2adfd 100644
--- a/tests/test-pearl-views.el
+++ b/tests/test-pearl-views.el
@@ -123,8 +123,11 @@
;;; the view query fetches comments too
(ert-deftest test-pearl-view-issues-query-requests-comments ()
- "The Custom View query selects comments, so a view-populated list shows them."
- (should (string-match-p "comments[[:space:]]*{[[:space:]]*nodes" pearl--view-issues-query)))
+ "The Custom View query selects capped, newest-first comments by default."
+ (let ((pearl-fetch-comments-in-list t)
+ (pearl-list-comments-count-cap 25))
+ (should (string-match-p "comments(first: 26, orderBy: createdAt)[[:space:]]*{[[:space:]]*nodes"
+ (pearl--view-issues-query)))))
(provide 'test-pearl-views)
;;; test-pearl-views.el ends here