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