diff options
Diffstat (limited to 'pearl.el')
| -rw-r--r-- | pearl.el | 164 |
1 files changed, 136 insertions, 28 deletions
@@ -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 |
