diff options
| -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 () |
