diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-03 09:12:04 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-03 09:12:04 -0500 |
| commit | 1f729465d2247fb6882185e426860de869fe9b71 (patch) | |
| tree | 09902839c64633a583cdf696c7422ecc6cfd331d | |
| parent | 580309bb65a5e529c7aab5e58d3983a37c471023 (diff) | |
| download | pearl-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.
| -rw-r--r-- | README.org | 2 | ||||
| -rw-r--r-- | pearl.el | 164 | ||||
| -rw-r--r-- | tests/test-pearl-merge.el | 79 | ||||
| -rw-r--r-- | tests/test-pearl-output.el | 47 | ||||
| -rw-r--r-- | tests/test-pearl-refresh.el | 4 | ||||
| -rw-r--r-- | tests/test-pearl-views.el | 70 |
6 files changed, 336 insertions, 30 deletions
@@ -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 @@ -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 () |
