aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--pearl.el105
-rw-r--r--tests/test-pearl-grouping.el58
-rw-r--r--tests/test-pearl-output.el18
3 files changed, 157 insertions, 24 deletions
diff --git a/pearl.el b/pearl.el
index 236c95d..22c5ebe 100644
--- a/pearl.el
+++ b/pearl.el
@@ -1288,7 +1288,8 @@ display name) so the assignee @-tag can use the short form."
(when raw
(list :id (cdr (assoc 'id raw))
:name (cdr (assoc 'name raw))
- :type (cdr (assoc 'type raw)))))
+ :type (cdr (assoc 'type raw))
+ :position (cdr (assoc 'position raw)))))
(defun pearl--normalize-team (raw)
"Normalize a Linear team alist RAW to a plist, or nil."
@@ -1609,7 +1610,7 @@ populated list renders them, not just the single-issue refresh."
issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
nodes {
id identifier title description priority url updatedAt
- state { id name type }
+ state { id name type position }
assignee { id name displayName email }
team { id key name }
project { id name }
@@ -1625,7 +1626,7 @@ populated list renders them, not just the single-issue refresh."
"query Issue($id: String!) {
issue(id: $id) {
id identifier title description priority url updatedAt
- state { id name type }
+ state { id name type position }
assignee { id name displayName email }
team { id key name }
project { id name }
@@ -1766,20 +1767,67 @@ view. Returns nil for a grouping pearl doesn't section (see
(t (format "Cycle %s" (plist-get cycle :number))))))
(_ nil)))
+(defconst pearl--state-type-order
+ '(("triage" . 0) ("backlog" . 1) ("unstarted" . 2)
+ ("started" . 3) ("completed" . 4) ("canceled" . 5))
+ "Rank of each Linear workflow-state type, matching the board's column order.
+Used as the primary key when ordering `workflowState' group sections; the
+state's `position' breaks ties within a type.")
+
+(defun pearl--state-type-rank (type)
+ "Return the board-order rank of a workflow-state TYPE (unknown sorts last)."
+ (or (cdr (assoc type pearl--state-type-order)) 6))
+
+(defun pearl--priority-group-rank (priority)
+ "Return the group-order rank of a Linear PRIORITY number.
+Urgent first .. Low, then None last -- matching how Linear orders a
+priority-grouped board. A nil or 0 priority is None."
+ (pcase priority (1 0) (2 1) (3 2) (4 3) (_ 4)))
+
+(defun pearl--order-groups (grouping groups)
+ "Order GROUPS, a list of (LABEL . ISSUES), by GROUPING's natural order.
+`workflowState' orders by (state type rank, state position) so the sections
+match Linear's board columns; `priority' orders Urgent..None; every other
+dimension sorts alphabetically by label. The sort is stable, so issues that
+tie on the key keep their first-appearance order."
+ (pcase grouping
+ ("workflowState"
+ (sort (copy-sequence groups)
+ (lambda (a b)
+ (let* ((sa (plist-get (cadr a) :state))
+ (sb (plist-get (cadr b) :state))
+ (ra (pearl--state-type-rank (plist-get sa :type)))
+ (rb (pearl--state-type-rank (plist-get sb :type))))
+ (if (/= ra rb)
+ (< ra rb)
+ (< (or (plist-get sa :position) 0)
+ (or (plist-get sb :position) 0)))))))
+ ("priority"
+ (sort (copy-sequence groups)
+ (lambda (a b)
+ (< (pearl--priority-group-rank (plist-get (cadr a) :priority))
+ (pearl--priority-group-rank (plist-get (cadr b) :priority))))))
+ (_
+ (sort (copy-sequence groups)
+ (lambda (a b) (string< (downcase (or (car a) "")) (downcase (or (car b) ""))))))))
+
(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."
+Returns a list of (LABEL . ISSUES) ordered by `pearl--order-groups' (Linear's
+board order for `workflowState'/`priority', alphabetical otherwise); within each
+group the issues keep their input order so the view's configured sort survives.
+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)))))
+ (pearl--order-groups
+ grouping
+ (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.
@@ -1795,7 +1843,7 @@ and so isn't applied by the connection alone (see
issues(first: $first, after: $after, sort: $sort, filter: $filter) {
nodes {
id identifier title description priority url updatedAt
- state { id name type }
+ state { id name type position }
assignee { id name displayName email }
team { id key name }
project { id name }
@@ -2894,6 +2942,7 @@ identifier prefix -- so an unedited heading is a no-op on title sync)."
(format ":LINEAR-STATE-ID-SYNCED: %s\n" (or (plist-get state :id) ""))
(format ":LINEAR-STATE-NAME: %s\n" (or (plist-get state :name) ""))
(format ":LINEAR-STATE-TYPE: %s\n" (or (plist-get state :type) ""))
+ (format ":LINEAR-STATE-POSITION: %s\n" (or (plist-get state :position) ""))
(format ":LINEAR-ASSIGNEE-ID: %s\n" (or (plist-get assignee :id) ""))
(format ":LINEAR-ASSIGNEE-ID-SYNCED: %s\n" (or (plist-get assignee :id) ""))
(format ":LINEAR-ASSIGNEE-NAME: %s\n" (or (plist-get assignee :name) ""))
@@ -7011,6 +7060,18 @@ GROUPING outside the drawer-backed set yields nil."
(string-to-number (or (org-entry-get nil "LINEAR-PRIORITY") "0"))))
(_ nil)))
+(defun pearl--buffer-issue-group-rep ()
+ "Return a minimal issue plist for group ordering, read from the drawer at point.
+Carries just the fields `pearl--order-groups' needs -- the state type/position
+and the priority -- so a buffer regroup orders its sections the same way the
+render path does. Point must be on the issue heading."
+ (let ((pos (org-entry-get nil "LINEAR-STATE-POSITION"))
+ (pri (org-entry-get nil "LINEAR-PRIORITY")))
+ (list :state (list :type (org-entry-get nil "LINEAR-STATE-TYPE")
+ :position (and pos (not (string-empty-p pos))
+ (string-to-number pos)))
+ :priority (and pri (not (string-empty-p pri)) (string-to-number pri)))))
+
(defun pearl--subtree-contains-issue-p (beg end)
"Return non-nil when the region BEG..END holds an issue heading (a `LINEAR-ID')."
(save-excursion
@@ -7054,6 +7115,7 @@ grouped. Returns t, or `no-issues' when there are none."
;; A level-2 issue (flat buffer): already canonical level 2.
((and id (not (string-empty-p id)))
(push (list :label (pearl--buffer-issue-group-label grouping)
+ :rep (pearl--buffer-issue-group-rep)
:text (buffer-substring-no-properties beg end))
issues))
;; A level-2 group heading (contains issues): descend, capture each
@@ -7073,6 +7135,7 @@ grouped. Returns t, or `no-issues' when there are none."
(iend (save-excursion (org-end-of-subtree t t) (point)))
(lvl (org-current-level)))
(push (list :label (pearl--buffer-issue-group-label grouping)
+ :rep (pearl--buffer-issue-group-rep)
:text (pearl--shift-heading-levels
(buffer-substring-no-properties ibeg iend)
(- 2 lvl)))
@@ -7088,15 +7151,25 @@ grouped. Returns t, or `no-issues' when there are none."
(delete-region content-beg parent-end)
(goto-char content-beg)
(if grouping
- (let ((order '()) (table (make-hash-table :test 'equal)))
+ (let ((order '()) (table (make-hash-table :test 'equal))
+ (reps (make-hash-table :test 'equal)))
(dolist (it issues)
(let ((label (plist-get it :label)))
- (unless (gethash label table) (push label order))
+ (unless (gethash label table)
+ (push label order)
+ (puthash label (plist-get it :rep) reps))
(push it (gethash label table))))
- (dolist (label (nreverse order))
- (insert (format "** %s\n" label))
- (dolist (it (nreverse (gethash label table)))
- (insert (pearl--shift-heading-levels (plist-get it :text) 1)))))
+ ;; Order the sections the same way the render path does: a
+ ;; one-rep group per label fed through `pearl--order-groups'.
+ (let ((ordered (pearl--order-groups
+ grouping
+ (mapcar (lambda (l) (cons l (list (gethash l reps))))
+ (nreverse order)))))
+ (dolist (g ordered)
+ (let ((label (car g)))
+ (insert (format "** %s\n" label))
+ (dolist (it (nreverse (gethash label table)))
+ (insert (pearl--shift-heading-levels (plist-get it :text) 1)))))))
(dolist (it issues)
(insert (plist-get it :text))))
(dolist (m malformed) (insert m))
diff --git a/tests/test-pearl-grouping.el b/tests/test-pearl-grouping.el
index a30bcc1..5bcae1d 100644
--- a/tests/test-pearl-grouping.el
+++ b/tests/test-pearl-grouping.el
@@ -354,5 +354,63 @@ SE-1 has a comment subtree; SE-2 has no assignee.")
(should (string-match-p "^\\*\\* Apollo$" out))
(should-not (string-match-p "^\\*\\* In Progress$" out))))
+;;; group ordering (board order, not first-appearance)
+
+(ert-deftest test-pearl-order-groups-workflowstate-by-type-then-position ()
+ "workflowState groups order by state type rank, then position within a type."
+ (let ((groups (list (cons "In Progress" (list '(:state (:type "started" :position 3.0))))
+ (cons "Icebox" (list '(:state (:type "backlog" :position 1.0))))
+ (cons "Done" (list '(:state (:type "completed" :position 5.0))))
+ (cons "Inbox" (list '(:state (:type "backlog" :position 2.0)))))))
+ (should (equal (mapcar #'car (pearl--order-groups "workflowState" groups))
+ '("Icebox" "Inbox" "In Progress" "Done")))))
+
+(ert-deftest test-pearl-order-groups-priority-urgent-first-none-last ()
+ "priority groups order Urgent..Low, then None last."
+ (let ((groups (list (cons "None" (list '(:priority 0)))
+ (cons "High" (list '(:priority 2)))
+ (cons "Urgent" (list '(:priority 1)))
+ (cons "Low" (list '(:priority 4))))))
+ (should (equal (mapcar #'car (pearl--order-groups "priority" groups))
+ '("Urgent" "High" "Low" "None")))))
+
+(ert-deftest test-pearl-order-groups-other-dimension-alphabetical ()
+ "A dimension with no board order (project) sorts alphabetically by label."
+ (let ((groups (list (cons "Zeta" (list '(:project (:name "Zeta"))))
+ (cons "alpha" (list '(:project (:name "alpha")))))))
+ (should (equal (mapcar #'car (pearl--order-groups "project" groups))
+ '("alpha" "Zeta")))))
+
+(ert-deftest test-pearl-group-issues-orders-by-board-order ()
+ "pearl--group-issues sections by board order even when input order differs."
+ (let ((issues (list '(:id "a" :state (:name "In Progress" :type "started" :position 3.0))
+ '(:id "b" :state (:name "Icebox" :type "backlog" :position 1.0)))))
+ (should (equal (mapcar #'car (pearl--group-issues issues "workflowState"))
+ '("Icebox" "In Progress")))))
+
+(defconst test-pearl-grouping--positioned
+ (concat
+ "#+LINEAR-SOURCE: (:type filter :name \"F\" :filter (:open t))\n\n"
+ "* F\n"
+ "** TODO SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n"
+ ":LINEAR-STATE-NAME: In Progress\n:LINEAR-STATE-TYPE: started\n:LINEAR-STATE-POSITION: 3.0\n:END:\nBody.\n"
+ "** TODO SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n"
+ ":LINEAR-STATE-NAME: Icebox\n:LINEAR-STATE-TYPE: backlog\n:LINEAR-STATE-POSITION: 1.0\n:END:\nBody.\n")
+ "Flat buffer where first-appearance (In Progress, Icebox) differs from board
+order (Icebox before In Progress).")
+
+(ert-deftest test-pearl-regroup-orders-sections-by-board-order ()
+ "Regrouping by status orders the sections by Linear's board order, not arrival."
+ (test-pearl-grouping--in-org test-pearl-grouping--positioned
+ (should (eq (pearl--regroup-issue-subtrees "workflowState") t))
+ (should (equal (test-pearl-grouping--group-headings) '("Icebox" "In Progress")))))
+
+(ert-deftest test-pearl-format-issue-renders-state-position ()
+ "The issue drawer carries LINEAR-STATE-POSITION so an offline regroup can order."
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "x" :identifier "SE-9" :title "T"
+ :state (:name "S" :type "started" :position 2.5)))))
+ (should (string-match-p "LINEAR-STATE-POSITION:[[:space:]]+2\\.5" out))))
+
(provide 'test-pearl-grouping)
;;; test-pearl-grouping.el ends here
diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el
index 55f7886..1035ff2 100644
--- a/tests/test-pearl-output.el
+++ b/tests/test-pearl-output.el
@@ -90,7 +90,7 @@ cookie cycling reaches Low regardless of the user's global `org-priority-lowest'
(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."
+in Linear board order (state type rank), 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"
@@ -104,14 +104,16 @@ in first-appearance order, and renders issues at level 3 under them."
(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))
+ ;; Board order: Backlog (backlog-type) sorts before Started (started-type),
+ ;; regardless of which appeared first in the input.
+ (should (< (string-match "^\\*\\* Backlog$" out)
+ (string-match "^\\*\\* Started$" out)))
+ ;; The first group heading carries no LINEAR-ID, so save/merge skip it: it
+ ;; sits ahead of the first issue drawer.
+ (let ((first-group (string-match "^\\*\\* Backlog$" out))
(next-id (string-match "LINEAR-ID:" out)))
- (should started)
- (should (< started next-id)))))
+ (should first-group)
+ (should (< first-group 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."