aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-03 09:12:04 -0500
committerCraig Jennings <c@cjennings.net>2026-06-03 09:12:04 -0500
commit1f729465d2247fb6882185e426860de869fe9b71 (patch)
tree09902839c64633a583cdf696c7422ecc6cfd331d /pearl.el
parent580309bb65a5e529c7aab5e58d3983a37c471023 (diff)
downloadpearl-1f729465d2247fb6882185e426860de869fe9b71.tar.gz
pearl-1f729465d2247fb6882185e426860de869fe9b71.zip
feat(views): render a Linear view's grouping as Org sections
A Linear view can group its issues (by status, project, assignee, priority, cycle), and until now pearl ignored that: a "by status" or "by project" view came back as one flat list and didn't look like itself. The sort half of matching the view UI shipped earlier. This is the grouping half. I read issueGrouping off the view preferences (next to the ordering pearl already fetches) and stamp it on the source as :group. When it's a single-valued dimension, the builder partitions issues into groups in first-appearance order and renders each as a level-2 section heading with its issues at level 3. An issue with no value for the dimension lands in a "No project" / "No assignee" bucket, mirroring Linear. The fold depth tracks where issues render (show3levels grouped, show2levels flat), so the buffer always opens unfolded to the issue level. issueGrouping "none", label-grouping (an issue carries several labels, deferred), and sub-grouping render the flat list. The group headings carry no LINEAR-ID, so save and merge skip them like the help header. Two merge points needed grouping-awareness: an in-place update now re-renders at the issue's existing heading level rather than flattening to level 2, and a new issue on refresh is placed under its group section (creating the section if it doesn't exist yet) instead of appended at the end. Both entry points share the resolver, so run-linear-view and a favorited view picked in pearl-pick-source group the same way. Verified live against the workspace: a by-project view renders one section per project plus a "No project" bucket, issues at level 3, opened to show3levels.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el164
1 files changed, 136 insertions, 28 deletions
diff --git a/pearl.el b/pearl.el
index 1b51577..2537cf7 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1660,6 +1660,51 @@ no extra filtering is needed:
(list (cons "canceledAt" (list (cons "gte" iso)))))))))
(t nil)))
+(defconst pearl--view-supported-groupings
+ '("workflowState" "project" "assignee" "priority" "cycle")
+ "Linear `issueGrouping' values pearl renders as Org sections.
+Single-valued dimensions only -- each issue lands in exactly one group. Label
+grouping (an issue carries several labels, so Linear files it under each) and
+sub-grouping are deferred; \"none\", nil, and any value outside this set render
+the flat list.")
+
+(defun pearl--view-grouping-active-p (grouping)
+ "Return non-nil when GROUPING is one pearl sections into Org headings.
+See `pearl--view-supported-groupings'."
+ (and (member grouping pearl--view-supported-groupings) t))
+
+(defun pearl--view-group-label (issue grouping)
+ "Return the group-section label for normalized ISSUE under GROUPING.
+GROUPING is a Linear `issueGrouping' string. A dimension with no value gets a
+stable \"No X\" bucket, mirroring how Linear shows unset issues in a grouped
+view. Returns nil for a grouping pearl doesn't section (see
+`pearl--view-grouping-active-p')."
+ (pcase grouping
+ ("workflowState" (or (plist-get (plist-get issue :state) :name) "No status"))
+ ("project" (or (plist-get (plist-get issue :project) :name) "No project"))
+ ("assignee" (or (plist-get (plist-get issue :assignee) :name) "No assignee"))
+ ("priority" (pearl--get-linear-priority-name (plist-get issue :priority)))
+ ("cycle" (let ((cycle (plist-get issue :cycle)))
+ (cond ((null cycle) "No cycle")
+ ((plist-get cycle :name))
+ (t (format "Cycle %s" (plist-get cycle :number))))))
+ (_ nil)))
+
+(defun pearl--group-issues (issues grouping)
+ "Partition ISSUES into ordered groups by GROUPING.
+Returns a list of (LABEL . ISSUES): groups in first-appearance order, and within
+each group the issues keep their input order so the view's configured sort
+survives inside every section. Returns nil when GROUPING isn't sectioned (see
+`pearl--view-grouping-active-p'), signaling the caller to render the flat list."
+ (when (pearl--view-grouping-active-p grouping)
+ (let ((order '()) (table (make-hash-table :test 'equal)))
+ (dolist (issue issues)
+ (let ((label (pearl--view-group-label issue grouping)))
+ (unless (gethash label table) (push label order))
+ (push issue (gethash label table))))
+ (mapcar (lambda (label) (cons label (nreverse (gethash label table))))
+ (nreverse order)))))
+
(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
@@ -1741,7 +1786,7 @@ Selects no `url' -- Linear's `CustomView' type has none, and requesting it
"query CustomViews($first: Int!, $after: String) {
customViews(first: $first, after: $after) {
nodes { id name description shared team { id }
- viewPreferencesValues { viewOrdering viewOrderingDirection showCompletedIssues } }
+ viewPreferencesValues { viewOrdering viewOrderingDirection showCompletedIssues issueGrouping } }
pageInfo { hasNextPage endCursor }
}
}")
@@ -2683,8 +2728,10 @@ count to the heading (the bulk list passes the issue's `:comment-count')."
(concat "*** Comments" (pearl--comment-count-marker count-info) "\n"
(mapconcat #'pearl--format-comment sorted "")))))
-(defun pearl--format-issue-as-org-entry (issue)
+(defun pearl--format-issue-as-org-entry (issue &optional level)
"Format a normalized ISSUE plist as an Org entry.
+LEVEL is the heading depth (number of leading stars), defaulting to 2 -- a
+grouped view renders issues at level 3 under their group section heading.
The heading carries the title; structured fields live in a namespaced
`LINEAR-*' property drawer (changed via commands, not by hand); the issue
description renders as the entry body. `LINEAR-DESC-SHA256' (the markdown) and
@@ -2710,9 +2757,10 @@ identifier prefix -- so an unedited heading is a no-op on title sync)."
(plist-get issue :labels) " "))
(tags (pearl--label-tags (plist-get issue :labels)))
(tag-string (if tags (concat " :" (string-join tags ":") ":") ""))
+ (stars (make-string (or level 2) ?*))
(body-org (pearl--md-to-org description)))
(concat
- (format "** %s %s%s%s\n" todo
+ (format "%s %s %s%s%s\n" stars todo
(if (string-empty-p priority) "" (concat priority " "))
heading-title tag-string)
":PROPERTIES:\n"
@@ -3269,10 +3317,11 @@ matches a first fetch."
(save-excursion
(org-back-to-heading t)
(let ((beg (point))
+ (level (org-current-level))
(end (save-excursion (org-end-of-subtree t t) (point))))
(delete-region beg end)
(goto-char beg)
- (insert (pearl--format-issue-as-org-entry issue))
+ (insert (pearl--format-issue-as-org-entry issue level))
;; Close the rewritten subtree's drawer(s) but leave the issue itself
;; expanded -- a single-issue refresh keeps the user on the issue they
;; were looking at, unlike a full repopulation which re-folds the page.
@@ -3872,12 +3921,16 @@ this file; nil (legacy single-account mode) omits the marker. Pure function,
no side effects."
(let* ((src (or source '(:type filter :name "Linear issues" :filter nil)))
(name (pearl--source-name src))
- (filter (plist-get src :filter)))
+ (filter (plist-get src :filter))
+ (grouping (plist-get src :group))
+ (groups (pearl--group-issues issues grouping)))
(with-temp-buffer
;; The view name renders verbatim, matching Linear's own capitalization;
;; `pearl-title-case-headings' governs issue headings only.
(insert (format "#+title: Linear — %s\n" name))
- (insert "#+STARTUP: show2levels\n")
+ ;; Fold depth tracks where issues render: level 2 flat, level 3 grouped,
+ ;; so the buffer always opens unfolded down to the issue headings.
+ (insert (format "#+STARTUP: show%dlevels\n" (if groups 3 2)))
(insert (format "#+TODO: %s\n" (pearl--derive-todo-line states)))
;; Source-tracking metadata: the serialized source drives refresh; the
;; rest is human-readable provenance.
@@ -3896,12 +3949,21 @@ no side effects."
(insert (pearl--help-header-string)))
;; Single top-level parent so the issues are sortable as a group
- ;; (org-sort on this heading) instead of orphan headings, and so a
- ;; show2levels fold has a level-1 root. Named after the view.
+ ;; (org-sort on this heading) instead of orphan headings, and so the
+ ;; fold has a level-1 root. Named after the view.
(insert (format "* %s\n" name))
- (dolist (issue issues)
- (insert (pearl--format-issue-as-org-entry issue)))
+ ;; A grouped view (issueGrouping on the source) sections issues under
+ ;; level-2 group headings, issues at level 3; the group headings carry no
+ ;; LINEAR-ID, so save/merge skip them like the help header. An
+ ;; ungrouped view keeps the flat level-2 list.
+ (if groups
+ (dolist (group groups)
+ (insert (format "** %s\n" (car group)))
+ (dolist (issue (cdr group))
+ (insert (pearl--format-issue-as-org-entry issue 3))))
+ (dolist (issue issues)
+ (insert (pearl--format-issue-as-org-entry issue))))
(buffer-string))))
@@ -3994,7 +4056,8 @@ just writing the file and leaving it off-screen."
(pearl--surface-buffer existing-buf))
(?m ; merge by LINEAR-ID, keeping edits
(with-current-buffer existing-buf
- (let ((counts (pearl--merge-issues-into-buffer issues)))
+ (let ((counts (pearl--merge-issues-into-buffer
+ issues (plist-get source :group))))
(pearl--update-source-header (length issues) truncated)
(pearl--update-derived-todo-header (pearl--gather-header-states issues))
(pearl-highlight-comments)
@@ -6161,14 +6224,50 @@ A merge refresh uses it so it keeps an issue you have touched in any field, not
just ones whose description changed."
(pearl--issue-has-dirty-fields-p (pearl--issue-dirty-fields)))
-(defun pearl--merge-issues-into-buffer (issues)
+(defun pearl--find-group-heading (label)
+ "Return a marker at the start of the level-2 group heading titled LABEL, or nil.
+Group headings are the bare `** LABEL' sections a grouped view emits; they carry
+no `LINEAR-ID' (issue headings, even at level 2 in a flat buffer, do), so the
+id check keeps this from matching an issue heading."
+ (save-excursion
+ (goto-char (point-min))
+ (let ((case-fold-search nil) found)
+ (while (and (not found)
+ (re-search-forward "^\\*\\* \\(.+\\)$" nil t))
+ (when (and (string= (match-string 1) label)
+ (not (org-entry-get nil "LINEAR-ID")))
+ (setq found (copy-marker (match-beginning 0)))))
+ found)))
+
+(defun pearl--merge-append-grouped (issue grouping)
+ "Insert a new ISSUE under its group section in a grouped buffer, returning a
+marker at the inserted heading. Appends at the end of the matching group's
+subtree (so the issue joins its section), creating the `** LABEL' heading at the
+end of the buffer first when the group doesn't exist yet."
+ (let* ((label (pearl--view-group-label issue grouping))
+ (group (pearl--find-group-heading label)))
+ (save-excursion
+ (if group
+ (progn (goto-char group) (org-end-of-subtree t t))
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (insert (format "** %s\n" label)))
+ (unless (bolp) (insert "\n"))
+ (let ((start (point-marker)))
+ (insert (pearl--format-issue-as-org-entry issue 3))
+ start))))
+
+(defun pearl--merge-issues-into-buffer (issues &optional grouping)
"Merge normalized ISSUES into the current buffer by `LINEAR-ID'.
Same-source refresh semantics: an existing issue still in ISSUES is re-rendered
-in place; an issue new to ISSUES is appended after the last one; an issue no
-longer in ISSUES is dropped. A subtree with any unpushed local edit (see
-`pearl--subtree-has-local-edits-p' -- title, state, priority, assignee, labels,
-description, or a comment) is never overwritten and never dropped; it is kept
-and counted, so a refresh can't lose un-synced work.
+in place (at its current heading level); an issue new to ISSUES is appended; an
+issue no longer in ISSUES is dropped. A subtree with any unpushed local edit
+(see `pearl--subtree-has-local-edits-p' -- title, state, priority, assignee,
+labels, description, or a comment) is never overwritten and never dropped; it is
+kept and counted, so a refresh can't lose un-synced work.
+GROUPING is the view's `issueGrouping' (nil for a flat buffer): when it sections
+the view (see `pearl--view-grouping-active-p'), a new issue is placed under its
+group section rather than appended at the end.
Returns a plist of counts plus markers:
\(:updated N :added N :dropped N :skipped N :touched-markers (M1 M2 ...))
@@ -6209,15 +6308,20 @@ edit-then-merge flow keeps the user's expanded subtrees expanded."
(org-back-to-heading t)
(delete-region (point) (progn (org-end-of-subtree t t) (point)))
(setq dropped (1+ dropped))))))
- ;; Issues new to the result: append after the last one, in fetched order.
+ ;; Issues new to the result: place under their group (grouped buffer) or
+ ;; append after the last one (flat buffer), in fetched order.
(dolist (issue issues)
(unless (assoc (plist-get issue :id) existing)
- (save-excursion
- (goto-char (point-max))
- (unless (bolp) (insert "\n"))
- (let ((insertion-start (point-marker)))
- (insert (pearl--format-issue-as-org-entry issue))
- (push insertion-start touched-markers)))
+ (let ((insertion-start
+ (if (pearl--view-grouping-active-p grouping)
+ (pearl--merge-append-grouped issue grouping)
+ (save-excursion
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (let ((m (point-marker)))
+ (insert (pearl--format-issue-as-org-entry issue))
+ m)))))
+ (push insertion-start touched-markers))
(setq added (1+ added))))
(list :updated updated :added added :dropped dropped :skipped skipped
:touched-markers (nreverse touched-markers))))
@@ -6291,7 +6395,8 @@ of the replace path."
(plist-get source :sort)
(plist-get source :order)))
(truncated (pearl--query-result-truncated-p result))
- (counts (pearl--merge-issues-into-buffer issues)))
+ (counts (pearl--merge-issues-into-buffer
+ issues (plist-get source :group))))
(pearl--update-source-header (length issues) truncated)
(pearl--update-derived-todo-header (pearl--gather-header-states issues))
(pearl-highlight-comments)
@@ -6358,7 +6463,8 @@ house sort and shows all issues."
(let ((prefs (cdr (assoc 'viewPreferencesValues view))))
(list :sort (pearl--view-sort-arg (cdr (assoc 'viewOrdering prefs))
(cdr (assoc 'viewOrderingDirection prefs)))
- :show-completed (cdr (assoc 'showCompletedIssues prefs)))))
+ :show-completed (cdr (assoc 'showCompletedIssues prefs))
+ :group (cdr (assoc 'issueGrouping prefs)))))
(defun pearl--run-view-source (source)
"Run a view SOURCE (`:type view', at least `:id'/`:name') and render it.
@@ -6373,7 +6479,8 @@ ignore the view's sort and show its completed/canceled issues."
(pearl--custom-views))))
(sort (plist-get prefs :sort))
(show-completed (plist-get prefs :show-completed))
- (source (append source (list :sort sort :show-completed show-completed))))
+ (source (append source (list :sort sort :show-completed show-completed
+ :group (plist-get prefs :group)))))
(pearl--progress "Running view %s..." (plist-get source :name))
(pearl--query-view-async
(plist-get source :id)
@@ -6403,7 +6510,8 @@ dirty-buffer guard) and records the view as the active source."
(source (list :type 'view :name view-name :id view-id
:url (pearl--view-url view-id)
:sort sort
- :show-completed show-completed)))
+ :show-completed show-completed
+ :group (plist-get prefs :group))))
(pearl--progress "Running view %s..." view-name)
(pearl--query-view-async
view-id