aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.org2
-rw-r--r--pearl.el164
-rw-r--r--tests/test-pearl-merge.el79
-rw-r--r--tests/test-pearl-output.el47
-rw-r--r--tests/test-pearl-refresh.el4
-rw-r--r--tests/test-pearl-views.el70
6 files changed, 336 insertions, 30 deletions
diff --git a/README.org b/README.org
index c98412c..3c5f42c 100644
--- a/README.org
+++ b/README.org
@@ -280,6 +280,8 @@ Linear stores a view's filter as an =and=/=or= tree that's richer than Pearl's A
Running a Linear Custom View (via =pearl-run-linear-view= or by picking a favorited view in =pearl-pick-source=) mirrors the view as Linear shows it: its own filter runs server-side, its configured sort order is reproduced, and its "completed issues" display setting is honored. A view set to hide completed issues shows only open issues in Pearl too; a view set to show completed issues from the past day/week/month/quarter/year shows open issues plus those completed or canceled within that window (the windows are fixed-day approximations of Linear's). Refreshing the view recomputes the window relative to the refresh time.
+A view that groups its issues (by status, project, assignee, priority, or cycle) renders each group as an Org section: the issues sit one level deeper under a group heading, and the buffer opens unfolded to the issue level. The group headings are local structure (no =LINEAR-ID=), so editing and saving an issue is unaffected and a refresh keeps issues under the right group. Grouping by label (an issue can carry several) and sub-grouping aren't rendered yet -- those views fall back to a flat list.
+
Ad-hoc filtering starts with a team, then completes states, projects, and labels from that team's actual Linear values. The State and Labels prompts are multi-select (comma-separated): pick several states to match issues in any of them, e.g. =(:state ("Todo" "In Review"))=. The assignee prompt offers =me=, a specific =member= (resolved to a user id), or =any= (no scoping). Local views are local Lisp data:
#+begin_src emacs-lisp
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
diff --git a/tests/test-pearl-merge.el b/tests/test-pearl-merge.el
index 931849d..9f9307c 100644
--- a/tests/test-pearl-merge.el
+++ b/tests/test-pearl-merge.el
@@ -72,6 +72,22 @@
"#+LINEAR-TRUNCATED: no\n\n"
(mapconcat #'pearl--format-issue-as-org-entry issues "")))
+(defun test-pearl-merge--issue-st (id title state-name state-type)
+ "A normalized issue plist in state STATE-NAME/STATE-TYPE, for grouping tests."
+ (list :id id :identifier (concat "ENG-" id) :title title
+ :description (concat "Desc " id ".")
+ :priority 2 :url (concat "https://linear.app/x/" id)
+ :updated-at "2026-05-23T03:00:00.000Z"
+ :state (list :id (concat "s-" state-name) :name state-name :type state-type)
+ :team (list :id "t1" :key "ENG" :name "Engineering")))
+
+(defun test-pearl-merge--grouped-buffer (grouping &rest issues)
+ "Render ISSUES into a grouped view buffer (no help header, network stubbed)."
+ (let ((pearl-help-header nil))
+ (cl-letf (((symbol-function 'pearl--team-states) (lambda (_team-id) nil)))
+ (pearl--build-org-content
+ issues (list :type 'view :name "By status" :id "v1" :group grouping)))))
+
;;; --merge-issues-into-buffer
(ert-deftest test-pearl-merge-updates-existing-in-place ()
@@ -123,6 +139,69 @@
(goto-char (point-min))
(should-not (re-search-forward "Beta" nil t)))))
+;;; grouped-view merge (issueGrouping sections)
+
+(ert-deftest test-pearl-merge-grouped-update-preserves-level ()
+ "Updating an issue in a grouped buffer re-renders it at level 3 (under its
+group), not promoted to level 2 the way a flat append would render it."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--grouped-buffer
+ "workflowState"
+ (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted")
+ (test-pearl-merge--issue-st "b" "Beta" "Done" "completed"))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue-st "a" "Alpha Renamed" "Todo" "unstarted")
+ (test-pearl-merge--issue-st "b" "Beta" "Done" "completed"))
+ "workflowState")))
+ (should (= 2 (plist-get counts :updated)))
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\*\\* .*Alpha Renamed" nil t))
+ ;; Not promoted to a level-2 heading.
+ (should-not (save-excursion (goto-char (point-min))
+ (re-search-forward "^\\*\\* [A-Z].*Alpha Renamed" nil t))))))
+
+(ert-deftest test-pearl-merge-grouped-new-issue-joins-existing-group ()
+ "A new issue whose group already exists is inserted under that group's section
+at level 3, not appended at the buffer end."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--grouped-buffer
+ "workflowState"
+ (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted")
+ (test-pearl-merge--issue-st "b" "Beta" "Done" "completed"))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted")
+ (test-pearl-merge--issue-st "b" "Beta" "Done" "completed")
+ (test-pearl-merge--issue-st "c" "Gamma" "Todo" "unstarted"))
+ "workflowState")))
+ (should (= 1 (plist-get counts :added)))
+ ;; Gamma renders at level 3, inside the Todo section (before Done).
+ (goto-char (point-min))
+ (let ((todo (re-search-forward "^\\*\\* Todo$" nil t))
+ (gamma (re-search-forward "^\\*\\*\\* .*Gamma" nil t))
+ (done (save-excursion (goto-char (point-min))
+ (re-search-forward "^\\*\\* Done$" nil t))))
+ (should (and todo gamma done))
+ (should (< todo gamma))
+ (should (< gamma done))))))
+
+(ert-deftest test-pearl-merge-grouped-new-issue-creates-missing-group ()
+ "A new issue whose group doesn't exist yet gets a fresh `** LABEL' section
+created for it, with the issue at level 3 under it."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--grouped-buffer
+ "workflowState"
+ (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted"))
+ (should-not (save-excursion (re-search-forward "^\\*\\* In Progress$" nil t)))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue-st "a" "Alpha" "Todo" "unstarted")
+ (test-pearl-merge--issue-st "d" "Delta" "In Progress" "started"))
+ "workflowState")))
+ (should (= 1 (plist-get counts :added)))
+ (goto-char (point-min))
+ (let ((grp (re-search-forward "^\\*\\* In Progress$" nil t)))
+ (should grp)
+ (should (re-search-forward "^\\*\\*\\* .*Delta" nil t))))))
+
(ert-deftest test-pearl-merge-keeps-unpushed-edit-on-update ()
"An existing subtree with unpushed body edits is kept, not overwritten."
(test-pearl-merge--in-org
diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el
index 8b02fcb..f02df5f 100644
--- a/tests/test-pearl-output.el
+++ b/tests/test-pearl-output.el
@@ -70,6 +70,51 @@
(should (string-match-p "^#\\+title:" out))
(should-not (string-match-p "^\\*\\*\\* " out))))
+;;; --build-org-content grouping (issueGrouping -> Org sections)
+
+(ert-deftest test-pearl-build-org-content-flat-startup-show2levels ()
+ "An ungrouped view folds to show2levels and renders issues at level 2."
+ (let* ((issues '((:id "1" :identifier "SE-1" :title "One"
+ :state (:name "Started" :type "started") :priority 0)))
+ (source '(:type view :name "Flat" :id "v1"))
+ (out (pearl--build-org-content issues source)))
+ (should (string-match-p "^#\\+STARTUP: show2levels$" out))
+ (should (string-match-p "^\\*\\* STARTED " out))
+ (should-not (string-match-p "^\\*\\*\\* " out))))
+
+(ert-deftest test-pearl-build-org-content-grouped-sections-and-startup ()
+ "A grouped view folds to show3levels, emits a level-2 group heading per group
+in first-appearance order, and renders issues at level 3 under them."
+ (let* ((issues '((:id "1" :identifier "SE-1" :title "One"
+ :state (:name "Started" :type "started") :priority 0)
+ (:id "2" :identifier "SE-2" :title "Two"
+ :state (:name "Backlog" :type "backlog") :priority 0)
+ (:id "3" :identifier "SE-3" :title "Three"
+ :state (:name "Started" :type "started") :priority 0)))
+ (source '(:type view :name "By status" :id "v1" :group "workflowState"))
+ (out (pearl--build-org-content issues source)))
+ (should (string-match-p "^#\\+STARTUP: show3levels$" out))
+ (should (string-match-p "^\\*\\* Started$" out))
+ (should (string-match-p "^\\*\\* Backlog$" out))
+ ;; Issues live at level 3 under the group headings.
+ (should (string-match-p "^\\*\\*\\* STARTED " out))
+ ;; First-appearance order: SE-1 (Started) before SE-2 (Backlog).
+ (should (< (string-match "^\\*\\* Started$" out)
+ (string-match "^\\*\\* Backlog$" out)))
+ ;; The group heading carries no LINEAR-ID, so save/merge skip it.
+ (let ((started (string-match "^\\*\\* Started$" out))
+ (next-id (string-match "LINEAR-ID:" out)))
+ (should started)
+ (should (< started next-id)))))
+
+(ert-deftest test-pearl-build-org-content-grouped-source-roundtrips ()
+ "A grouped view's source (with :group) reads back from the rendered header."
+ (let* ((source '(:type view :name "By project" :id "v1" :group "project"))
+ (out (pearl--build-org-content '() source)))
+ (with-temp-buffer
+ (insert out)
+ (should (equal source (pearl--read-active-source))))))
+
(ert-deftest test-pearl-build-org-content-title-uses-view-name-verbatim ()
"The view name in the file title matches Linear's capitalization verbatim,
independent of `pearl-title-case-headings' (which governs issue headings only)."
@@ -336,7 +381,7 @@ refresh prompt stubbed to return CHOICE. BUF and TMP are bound in BODY."
(let ((merged nil))
(test-pearl-output--with-dirty-buffer ?m
(cl-letf (((symbol-function 'pearl--merge-issues-into-buffer)
- (lambda (_issues) (setq merged t) '(:updated 1 :added 0 :dropped 0 :skipped 0)))
+ (lambda (_issues &optional _grouping) (setq merged t) '(:updated 1 :added 0 :dropped 0 :skipped 0)))
((symbol-function 'pearl--update-source-header) #'ignore)
((symbol-function 'pearl--update-derived-todo-header) #'ignore)
((symbol-function 'pearl-highlight-comments) #'ignore)
diff --git a/tests/test-pearl-refresh.el b/tests/test-pearl-refresh.el
index d09c96c..199b346 100644
--- a/tests/test-pearl-refresh.el
+++ b/tests/test-pearl-refresh.el
@@ -93,7 +93,9 @@
(re-search-forward "Stale Title")
(pearl-refresh-current-issue)
(goto-char (point-min))
- (re-search-forward "^\\*\\* ")
+ ;; refresh preserves the issue's heading level (here level 3) rather than
+ ;; flattening it -- a grouped view's issues stay at level 3 on refresh
+ (re-search-forward "^\\*\\*\\* ")
;; heading + drawer reflect the remote
(should (string-match-p "Refreshed Title" (thing-at-point 'line t)))
(should (string= "In Progress" (org-entry-get nil "LINEAR-STATE-NAME")))
diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el
index 3675200..7c8ec80 100644
--- a/tests/test-pearl-views.el
+++ b/tests/test-pearl-views.el
@@ -325,6 +325,76 @@ the pick-source path showed a view's completed/canceled issues and ignored sort.
;; The favorite's own keys survive the enrichment.
(should (string= "https://x" (plist-get rendered-source :url))))))
+;;; --view-grouping-active-p / --view-group-label / --group-issues
+
+(ert-deftest test-pearl-view-grouping-active-p-supported-types ()
+ "The single-valued grouping dimensions are active in v1."
+ (dolist (g '("workflowState" "project" "assignee" "priority" "cycle"))
+ (should (pearl--view-grouping-active-p g))))
+
+(ert-deftest test-pearl-view-grouping-active-p-deferred-and-none ()
+ "Label-grouping (multi-valued, deferred), none, nil, and unknown values are
+inactive, so the view renders flat."
+ (dolist (g '("label" "none" nil "someFutureGrouping"))
+ (should-not (pearl--view-grouping-active-p g))))
+
+(ert-deftest test-pearl-view-group-label-by-dimension ()
+ "Each supported grouping resolves an issue to its group label."
+ (let ((issue '(:state (:name "In Progress" :type "started")
+ :project (:name "Orchestration")
+ :assignee (:name "Eric")
+ :priority 1
+ :cycle (:number 4 :name "Sprint 4"))))
+ (should (string= "In Progress" (pearl--view-group-label issue "workflowState")))
+ (should (string= "Orchestration" (pearl--view-group-label issue "project")))
+ (should (string= "Eric" (pearl--view-group-label issue "assignee")))
+ (should (string= "Urgent" (pearl--view-group-label issue "priority")))
+ (should (string= "Sprint 4" (pearl--view-group-label issue "cycle")))))
+
+(ert-deftest test-pearl-view-group-label-missing-values ()
+ "A missing dimension value gets a stable \"No X\" bucket label, matching how
+Linear shows unset issues in a grouped view."
+ (let ((issue '(:priority 0)))
+ (should (string= "No status" (pearl--view-group-label issue "workflowState")))
+ (should (string= "No project" (pearl--view-group-label issue "project")))
+ (should (string= "No assignee" (pearl--view-group-label issue "assignee")))
+ (should (string= "No cycle" (pearl--view-group-label issue "cycle")))
+ (should (string= "None" (pearl--view-group-label issue "priority")))))
+
+(ert-deftest test-pearl-view-group-label-cycle-number-fallback ()
+ "A cycle with no name falls back to its number."
+ (should (string= "Cycle 7"
+ (pearl--view-group-label '(:cycle (:number 7)) "cycle"))))
+
+(ert-deftest test-pearl-group-issues-inactive-is-nil ()
+ "An inactive grouping returns nil, signaling the caller to render flat."
+ (should-not (pearl--group-issues '((:id "a" :state (:name "Todo"))) "none"))
+ (should-not (pearl--group-issues '((:id "a" :state (:name "Todo"))) nil)))
+
+(ert-deftest test-pearl-group-issues-orders-by-first-appearance ()
+ "Groups appear in the order their first issue does; issues keep their input
+order within a group (the layer-1 sort is preserved inside each section)."
+ (let* ((issues '((:id "1" :state (:name "Started"))
+ (:id "2" :state (:name "Backlog"))
+ (:id "3" :state (:name "Started"))
+ (:id "4" :state (:name "Backlog"))))
+ (groups (pearl--group-issues issues "workflowState")))
+ (should (equal '("Started" "Backlog") (mapcar #'car groups)))
+ (should (equal '("1" "3") (mapcar (lambda (i) (plist-get i :id))
+ (cdr (assoc "Started" groups)))))
+ (should (equal '("2" "4") (mapcar (lambda (i) (plist-get i :id))
+ (cdr (assoc "Backlog" groups)))))))
+
+;;; --view-node-prefs carries grouping
+
+(ert-deftest test-pearl-view-node-prefs-extracts-grouping ()
+ "The view node's issueGrouping is read into :group alongside sort and
+show-completed, so run/refresh can section the issues."
+ (let ((prefs (pearl--view-node-prefs
+ '((id . "v1")
+ (viewPreferencesValues (issueGrouping . "workflowState"))))))
+ (should (string= "workflowState" (plist-get prefs :group)))))
+
;;; refresh-current-view, view branch
(ert-deftest test-pearl-refresh-current-view-runs-view-source ()