aboutsummaryrefslogtreecommitdiff
path: root/tests
Commit message (Collapse)AuthorAgeFilesLines
* feat(views): regroup issue subtrees in place, preserving editsCraig Jennings2026-06-061-0/+144
| | | | | | | | Phase 2 of the interactive-grouping spec: the buffer transform, still no command. pearl--regroup-issue-subtrees re-lays-out the active view's issues by an issueGrouping value, or flattens them when given nil. It moves each issue subtree byte-for-byte and shifts only its heading stars, so descriptions, comments, and unsaved edits ride along untouched. The group label comes from the subtree's own drawer (pearl--buffer-issue-group-label), so the regroup matches a server-grouped render without a refetch. It works from either starting state. It climbs to the level-1 view parent, captures every issue (level-2 in a flat buffer, level-3 under a group heading in a grouped one) normalized to a canonical level, then re-emits them flat at level 2 or sectioned under level-2 group headings at level 3. Group headings are regenerated, so a stale one never lingers. A non-issue level-2 subtree is kept and placed after the groups rather than dropped. pearl--shift-heading-levels is the level mechanic, isolated and tested both directions. 29 tests cover the shift, grouping by each dimension, the unset bucket, regrouping to a new dimension, flatten and round-trip, edit preservation, malformed-last, and no-issues.
* feat(views): grouping source helpers and the effective-grouping resolverCraig Jennings2026-06-061-0/+146
| | | | | | | | Phase 1 of the interactive-grouping spec: the source/resolver plumbing, no command yet. pearl--grouping-choices maps the display labels (status/project/assignee/priority/none) to Linear's issueGrouping values. pearl--grouping-display-to-value coerces and pearl--check-grouping validates, both refusing anything outside that set. Cycle is left out on purpose: it isn't in the issue drawer, so an offline regroup can't read it. pearl--source-with-grouping records the choice under :client-group for every source type, leaving a view's own server :group intact. Choosing none removes the key, so the source falls back to the server :group or to flat. pearl--effective-grouping returns :client-group when set, else :group. That's the single read every grouping consumer routes through in phase 4, so a refresh reproduces the layout on screen. 16 tests: coercion, validation, the source helper, resolver precedence, and the :client-group header round-trip.
* fix(views): re-fold the page after an interactive sortCraig Jennings2026-06-061-0/+66
| | | | | | Setting a sort order opened every issue, drawer, and comment in the buffer. The reorder moves subtrees by deleting the issue block and re-inserting it, and freshly inserted Org text comes in fully expanded. The fold state lives in overlays that delete-and-insert drops. Neither sort path re-folded afterward, unlike the full-render and merge paths, which already do. Both sort paths now call pearl--restore-page-visibility after a successful reorder, re-folding the page to its #+STARTUP visibility so it stays the scannable outline it was before the sort. A refusal (a grouped buffer or a failed refetch) moves nothing, so it doesn't re-fold. The behavior honors pearl-fold-after-update like the other repopulating paths.
* feat(render): make heading glyphs configurableCraig Jennings2026-06-061-10/+57
| | | | | | The Comments container heading rendered bare. It carries no Linear id, so the id-keyed overlay never glyphed it, while each comment already showed a balloon. I gave the container its own glyph and split them so the thread and its items read as a hierarchy: a filled balloon (๐Ÿ’ฌ) on the Comments header, an outline bubble (๐Ÿ—จ๏ธ) on each comment, the ticket (๐ŸŽซ) unchanged on issues. I promoted the two glyph constants to defcustoms and added pearl-comments-header-glyph, so any glyph can be changed or dropped to empty while pearl-show-glyphs stays the master switch. The header glyph rides the same display overlay as the others, so the buffer text stays a bare Comments and sync and merge are untouched. The Comments header is now found by one glyph-agnostic regexp, replacing two copies of the old ๐Ÿ’ฌ-only matcher.
* feat(render): save the buffer to disk after a fetch or refreshCraig Jennings2026-06-051-0/+25
| | | | | | The full-replace render saved the Org file, but the in-place paths didn't, so a refresh or a comment fetch left the buffer modified-but-unsaved and the file on disk stale. Five paths now save through pearl--save-if-visiting: refresh-current-view, refresh-current-issue, fetch-all-comments, create-comment, and the merge branch of a dirty source-switch. The helper saves only when the buffer visits a file, so it's a no-op in a test buffer. Saving the Org file never pushes to Linear, so an unpushed edit still waits for pearl-save-issue. This only keeps the disk copy in step with what's on screen. fetch-all-comments surfaced the gap first. An audit turned up the other four.
* fix(render): serialize the source header in full regardless of print-lengthCraig Jennings2026-06-051-0/+12
| | | | | | The #+LINEAR-SOURCE descriptor was written with a bare prin1-to-string, which honors the user's print-length and print-level. A long source, like a Custom View's (it carries a sort vector, a url, and several keys), truncated to "..." for anyone whose config caps those (mine sits at 10). The header then read back as a malformed, odd-length plist, so refresh and the new sort commands errored on the view. A short source like my-open-issues fit under the cap and hid the bug. pearl--linear-source-string binds both to nil around the serialization, and the two header writers (the full render and the sort header rewrite) go through it. I caught it dogfooding the interactive sort live on a real view.
* fix(views): make a server-side sort actually reorder the bufferCraig Jennings2026-06-051-0/+10
| | | | | | A server-side sort (updated/created) refetched with the new orderBy and merged the result, but the merge updates issues in place by id and never moves them, so the buffer kept its old order and the sort had no visible effect. I caught it dogfooding live: sorting My Open Issues updated-ascending then updated-descending rendered an identical order. After the refetch I now reorder the buffer's issue subtrees to the fetched order. pearl--issue-id-ranks builds a LINEAR-ID to position map from the sorted result, and pearl--reorder-issue-subtrees takes an optional id-ranking and lays the subtrees out by it. The move is the same byte-for-byte subtree shuffle the client-side sort already uses, so unpushed edits survive. Re-checked live: updated-ascending now comes back the exact reverse of updated-descending.
* feat(comments): fetch an issue's full comment threadCraig Jennings2026-06-052-1/+131
| | | | | | pearl-fetch-all-comments pages the whole comment connection for the issue at point and replaces its Comments subtree with the complete set. It's bound to C-; L f c and in the transient. The bulk list and view fetch showed only the newest pearl-list-comments-shown comments with an N/M+ overflow marker, so anything past the cap couldn't surface in the buffer. The replacement is surgical: pearl--replace-comments-subtree swaps only the Comments subtree, so the issue title, drawer, and description body keep any unsaved edits, and the new heading carries an exact N/N count. It refuses when the issue has unpushed comment edits (reusing the save model's dirty scanner) so the fetch can't clobber an unsent change.
* feat(views): sort the active view interactivelyCraig Jennings2026-06-051-0/+294
| | | | | | | | pearl-set-sort reorders the current view by updated, created, priority, or title without hand-editing a source. pearl-toggle-sort-order flips asc/desc on the current sort, defaulting to updated descending. Both are M-x for now. The spec deferred a key binding until the menu layout settles. Priority and title sort client-side: pearl--reorder-issue-subtrees moves whole issue subtrees by LINEAR-ID, byte-for-byte, so unsaved description and comment edits survive and a non-issue heading is kept rather than dropped. Updated and created refetch with the new server orderBy. The #+LINEAR-SOURCE header is rewritten only after the reorder or refetch succeeds, so a failed sort never advertises an order the buffer doesn't show. Two things the 2026-05-24 spec predates and I had to settle. A view's :sort now holds Linear's IssueSortInput, so the interactive sort on a view persists under separate :client-sort/:client-order keys and pearl--effective-sort-order re-applies it on refresh. That keeps a view sort surviving a refresh the same way a filter sort does, rather than behaving differently on a distinction the user can't see. Grouped views aren't reorderable in place yet. They refuse with a message. A created/updated sort on a Custom View is refused too, since Linear gives views no server-side order.
* feat(render): overlay content glyphs on issue and comment headingsCraig Jennings2026-06-051-0/+126
| | | | | | A ticket glyph now sits on every issue heading and a speech balloon on every comment heading. The glyph rides a display overlay on the space after the leading stars, so it lands ahead of the TODO keyword where a literal text prefix can't go (Org wants the keyword first). The choice is keyed on the heading's own drawer, LINEAR-ID for issues and LINEAR-COMMENT-ID for comments, so it's per-heading-type rather than per outline level. Display-only: the buffer text is untouched, so parse, sync, and merge are unaffected. It reuses the comment-highlight overlay pattern and rides the same redecoration trigger. It's applied synchronously because the glyph is drawer-driven and shouldn't wait on the viewer lookup. Behind pearl-show-glyphs (default on). Overlays go stale after a manual heading edit until the next refresh.
* feat(compose): @-mention Linear users from a pickerCraig Jennings2026-06-032-2/+118
| | | | | | | | | | Mentioning a teammate meant recalling their exact Linear handle, dotted last names and all. Now a picker inserts it for you. In a comment or description compose buffer, typing @ at the start of a word pops a completing-read over the issue team's members, showing "Full Name (@handle)" so you can find someone by name, and inserts @displayName, the bare handle Linear resolves to a mention (no id or link wrapper). C-; L @ (pearl-mention-user) runs the same picker explicitly and also works inline in an issue buffer, resolving the team from the heading at point. An @ mid-word (an email) stays literal, and cancelling the picker leaves a literal @, so neither case is hijacked. The compose buffer now carries the issue's team id so the picker knows whom to offer. The @ trigger is bound only when a team is in context. @displayName survives the md/org round-trip unchanged, so it reaches Linear intact. Not yet confirmed: that writing a body with @displayName through the API fires Linear's mention notification (the web app does, and stored bodies carry the bare handle, so it almost certainly round-trips). Worth one self-mention write-test before relying on it.
* feat(render): pin the priority range to Linear's A-D in the rendered fileCraig Jennings2026-06-031-0/+6
| | | | | | | | Pearl maps Linear's five priorities to org cookies (A=Urgent through D=Low, no cookie = None) and tells users to set priority with org's native cookie cycling (C-c , / S-up). But that cycling obeys the user's global org-priority-lowest, which defaults to C, so on a stock Emacs you can't even cycle down to [#D]/Low, and the priority you most want to set is unreachable. I emit a #+PRIORITIES: A D D line into every rendered file (highest A, lowest D, default D). Org reads it per-file, so the range is bounded to Linear's scale and Low is reachable regardless of the user's global setting. Verified org picks it up: org-lowest-priority becomes ?D in the rendered buffer. This is the last piece of constraining cookies to Linear's range. The out-of-range guard (a cookie past D errors instead of silently clearing the priority) and the A-to-name legend in the help header already shipped.
* fix(render): nest comments under their issue in a grouped viewCraig Jennings2026-06-031-0/+26
| | | | | | In a grouped view, each issue's Comments subtree rendered at the issue's own level instead of one deeper, so the Comments heading sat as a sibling of its ticket rather than a child. The grouping feature gave pearl--format-issue-as-org-entry a level parameter (issues render at level 3 under a group heading) but left the comment subtree hardcoded: pearl--format-comments emitted "*** Comments" and pearl--format-comment "**** ". That nests right when the issue is at level 2 (flat) and breaks when it's at level 3 (grouped). I thread the issue's level through, so the Comments heading is level+1 and each comment body level+2: a flat issue stays 2/3/4, a grouped one becomes 3/4/5.
* feat(views): add pearl-assignee-tag-short to trim the @-tag to first nameCraig Jennings2026-06-031-0/+14
| | | | | | The assignee @-tag slugs Linear's full @-mention handle, so a firstname.lastname handle still renders long (:@first_last:). For someone who wants a tighter tag line, I added pearl-assignee-tag-short (default off): when on, the tag keeps only the first segment of the handle (before the first dot or space), so :@first_last: becomes :@first: and a single-token handle like alice is unchanged. It's opt-in because first names collide across teammates, so the default stays the unambiguous full handle.
* fix(views): slug the assignee @-tag from Linear's displayName handleCraig Jennings2026-06-031-0/+21
| | | | | | The assignee @-tag slugged the user's name field, which for a teammate who never set a display name in Linear is their full email. That produced tags like :@first_last_example_com: that wrap the heading's tag line across several lines. Linear's displayName is the short @-mention handle (e.g. "alice", "first.last"), consistent across users where name isn't. I normalize it onto the user as :display-name and slug the tag from it, falling back to name when it's absent. So an email-named teammate becomes :@first_last: and a handle-named one :@alice: instead of :@alice_smith:. The :LINEAR-ASSIGNEE-NAME drawer keeps the fuller name. Every issue query already selects displayName on the assignee, so no fetch change.
* feat(views): surface the issue assignee as an @-tag on the headingCraig Jennings2026-06-033-6/+80
| | | | | | | | On a team or "by person" view you couldn't tell who owned an issue at a glance: the assignee was captured but folded inside the :LINEAR-ASSIGNEE-NAME: drawer, with nothing in the heading. I render the assignee as a leading @-tag (:@eric:bug:backend:), behind a pearl-show-assignee defcustom (default on). I picked a tag over a heading suffix because Org strips tags before it hashes the title for sync, so the tag can't corrupt title-sync the way a suffix would. The @ prefix keeps it in its own namespace, separate from label tags: labels are tracked in their drawer, not read back from the heading, so the tag never gets mistaken for a label. The label-edit path preserves any @-tag when it rewrites the heading's tags, and edit-assignee refreshes the @-tag so a reassign doesn't leave a stale name until the next fetch. Verified live against a by-person view: assigned issues carry their @-tag, unassigned ones stay bare.
* feat(views): render a Linear view's grouping as Org sectionsCraig Jennings2026-06-034-2/+198
| | | | | | | | | | 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.
* fix(views): honor a Linear view's completed-issues display preferenceCraig Jennings2026-06-031-4/+157
| | | | | | | | | | A Linear view that hides completed issues still showed done and cancelled issues when run in pearl. The open-only restriction isn't in the view's filterData. It's a separate display preference, viewPreferencesValues.showCompletedIssues, which pearl fetched for sort but never applied. So the customView.issues connection returned every state type, and a "by status" or "open issues" view didn't look like itself. I now read showCompletedIssues alongside the sort fields and translate it to an IssueFilter passed to the connection, AND-combined with the view's own filter. "all" adds nothing, "none" excludes the closed state types (completed, canceled, duplicate), and a recency window (day/week/month/quarter/year) keeps open issues plus those completed or canceled within the window. I stamp the raw preference on the source so a refresh rebuilds the window relative to the refresh time rather than first-run time. Both entry points route through a shared pearl--view-node-prefs resolver, so pearl-run-linear-view and a favorited view picked in pearl-pick-source behave the same. That also closes a latent gap where the pick-source path ignored the view's configured sort. Verified live against the workspace: a "none" view returns only open issues, and a "week" view returns open issues plus those closed within seven days, with none older leaking through.
* feat(render): open a fetched view folded to the issue headingsCraig Jennings2026-06-032-5/+7
| | | | | | The header set #+STARTUP: show3levels, which opened a fresh view with each issue's Comments heading already unfolded. That's a level of detail past what you want when scanning a list. Drop to show2levels so a view opens at the parent plus the issue headings, with comments and bodies folded under each issue. pearl--restore-page-visibility re-reads #+STARTUP from the buffer after a repopulation, so the in-place rebuild path follows the new depth without any other change.
* test: narrow the no-file-id assertion to :ID: specificallyCraig Jennings2026-06-031-2/+5
| | | | | | test-pearl-build-org-content-no-shared-file-id guards against pearl leaking a hardcoded file-level :ID: drawer. It checked for that two ways: the leaked id literal, and a blanket "no :PROPERTIES: drawer anywhere." The foldable help header (3336442) then started emitting a legitimate heading-level :PROPERTIES: :VISIBILITY: folded drawer, which the blanket check caught, so the test failed on correct output. Anchor the second assertion on :ID: instead. It still catches an id leak, alongside the unchanged literal check, while letting the help header's :VISIBILITY: drawer through. Test-only change; the rendered output was already correct.
* feat(views): run a Linear view in its configured sort orderCraig Jennings2026-06-031-2/+84
| | | | | | | | A Linear view's issue query passed no orderBy, so issues came back in Linear's API default, createdAt descending. That's neither the order set on the view in Linear nor the updatedAt-desc the rest of pearl uses, and the two unrelated defaults colliding is what made a view's order look random. Linear exposes a view's configured sort in viewPreferencesValues (viewOrdering plus viewOrderingDirection), and the issues connection accepts an IssueSortInput sort arg that applies it server-side. I read the view's ordering when running it, translate it to that sort arg, and pass it through, so a view arrives in the order you gave it in Linear. The sort is stamped on the recorded source so a refresh keeps the same order. An ordering pearl can't map, or a view with no preference, falls back to updatedAt-desc rather than the createdAt default, so it lines up with every other pearl fetch. This is the sort half of matching the Linear UI. Grouping (issueGrouping into org sections) is the larger remaining half and is tracked separately.
* fix(views): rebuild Linear view URLs instead of querying the missing url fieldCraig Jennings2026-06-032-3/+53
| | | | | | | | Linear's CustomView type has no url field. Three GraphQL selections requested it (the custom-views list query and both the create and update mutation payloads), so each of those requests returned HTTP 400 and pearl got nil back. Running a Linear view came up with an empty picker, and publishing or updating a local view to Linear failed silently. The test suite stayed green because the view tests mocked a url field the real API never returns. I dropped url from all three selections. The workspace urlKey is now fetched once and cached, and a view's web URL is rebuilt as https://linear.app/<urlKey>/view/<view-id>, the shape Linear's own favorite URLs use (the full view UUID, not the slug id). pearl-open-current-view-in-linear keeps working, and when the urlKey is unavailable it degrades to nil so the link builder can't throw. That also stops a urlKey hiccup right after a successful publish from aborting the record step and orphaning the new view. I added regression locks asserting neither the list query nor the mutation payload selection requests url, since the field name is the schema contract that broke.
* feat(render): collapse the #+ preamble to a styled summary lineCraig Jennings2026-06-021-3/+60
| | | | | | | | The ~9-line #+ keyword block atop every Linear file is now hidden behind a display overlay showing one line: "Pearl - <view> ยท <count> ยท <time>", styled large and goldenrod via the new pearl-preamble-summary face. It leads with "Pearl" so it doubles as the buffer's identity marker, the thing that tells you you're in a Pearl buffer and not just another org file. A pearl-hide-preamble defcustom (default t) turns it off for the raw keywords. The overlay only governs display: the #+ text stays in the buffer, so #+LINEAR-SOURCE parsing, the merge rewrites of the RUN-AT / COUNT / TRUNCATED lines, and refresh all keep working untouched. The summary is composed from those same #+ values, so it stays accurate across merges. It re-applies idempotently on pearl-mode enable and after every render, refresh, and merge, so reopening a file hides it the same way a fresh fetch does. With the summary now carrying identity, the folded help heading reverts from "* Pearl" back to the descriptive "* Pearl Help".
* feat(render): foldable Pearl help header, dropping the # commentsCraig Jennings2026-06-021-3/+48
| | | | | | | | The preamble carried two raw "# ..." affordance comment lines on every Linear file. Those move into a folded "* Pearl" heading, gated by a new pearl-help-header defcustom (default t). It collapses on open via a :VISIBILITY: folded property and carries the edit-then-save reminder, a note that the section is never sent to Linear, the key cues (derived from pearl-keymap-prefix, falling back to M-x when no prefix is bound), the priority-cookie legend, and how to hide it. Set the defcustom to nil for a minimal preamble of just the machine keywords. The heading also doubles as the "you're in Pearl" marker at the top of the buffer. The heading has no LINEAR-ID, so save and merge skip it for free: they walk only LINEAR-ID-bearing subtrees. A forward declaration of pearl-keymap-prefix keeps the byte-compiler happy, since its defcustom lives later in the file than the renderer. This is the human-content half of the preamble work. The machine #+ keyword lines (title, STARTUP, TODO, LINEAR-SOURCE, the provenance fields) are still visible. Hiding those is the remaining part of the task.
* fix(priority): error on an out-of-range cookie instead of silent NoneCraig Jennings2026-06-021-0/+23
| | | | | | pearl--priority-number-at-point matched only [#A-D], so a cookie outside that range (a hand-typed [#E], say) didn't match and fell through to 0 (None). On save that silently pushed None to Linear and cleared the issue's priority, with no error and no sign anything was wrong. It now matches any [#A-Z] and, when the letter isn't A-D, signals a user-error naming the valid range (A=Urgent, B=High, C=Medium, D=Low, or no cookie for None), so a stray cookie surfaces before any push rather than quietly wiping the priority. Linear has exactly five priorities, so A-D plus no-cookie is the whole range.
* feat(keys): friendlier which-key labels for the prefix sub-mapsCraig Jennings2026-06-021-3/+3
| | | | | | The C-; L sub-prefixes were labelled +fetch / +views / +edit / +create / +delete / +open / +copy / +workspace, the which-key submenu idiom but terse. They now read fetch... / views... / edit... / create... and so on: drop the +, add an ellipsis so the label reads as "do this, then pick what." Leaf commands and the direct hot keys are unchanged. These labels are the (LABEL . COMMAND) menu-item strings baked into pearl-prefix-map, so which-key, describe-prefix, and which-key-less lookups all pick them up with no which-key dependency. Pure label strings, no behavior change. The keymap-label test moved to the new wording.
* feat(views): offer open-default-view in the transient menuCraig Jennings2026-06-021-0/+1
| | | | | | The keymap's l hot key opens the default view, but the transient could only run literal my-open-issues, so the menu couldn't reach a configured default. Now the Fetch group's l mirrors the keymap and runs pearl-open-default-view. Literal my-open-issues moves to m so it stays reachable from the menu. Suite holds at 809, compile and lint clean.
* feat(views): wire the default view into the keymap, transient, and docsCraig Jennings2026-06-022-12/+20
| | | | | | | | | | Phase 3, the last of the default-view spec: surface the phase 1-2 commands and flip the hot key over. Top-level l now runs pearl-open-default-view instead of pearl-list-issues, so it opens your default. The literal my-open-issues fetch stays reachable at f o, and l is unchanged until you set a default. v D binds pearl-set-default-view (the slot the keybinding reconciliation reserved), and the transient Views group gets a . suffix for it. D there stays save-locally. README documents the default view: what l does, how to set and clear it, the Customize persistence, and the per-account scoping under multiple accounts. Keymap and menu tests updated for the l rebind, the v D binding, and the transient suffix. Suite holds at 809, compile and lint clean.
* feat(views): set-default-view commandCraig Jennings2026-06-021-0/+117
| | | | | | | | | | Phase 2 of the default-view spec: the command that sets the default that phase 1 reads. It's the v D slot the keybinding work reserved (the binding itself lands in phase 3). From a buffer that's showing a local view, pearl-set-default-view offers that view as the default. Otherwise it prompts over the local-view names with a my-open-issues sentinel on top that clears the default. Quitting leaves it unchanged. pearl--persist-default-view writes through customize-save-variable so the choice survives a restart. In accounts mode it updates the active account's :default-view and preserves the account's other plist keys, the same metadata-preserving rewrite local-view edits use. In legacy mode it writes the global pearl-default-view. Eight tests: persist scope-targeting and key preservation in both modes, plus the offer, prompt, clear, and cancel paths. Suite 801 to 809, compile and lint clean.
* feat(views): default-view resolver and open commandCraig Jennings2026-06-021-0/+134
| | | | | | | | | | Phase 1 of the default-view spec. I added the data and dispatch; keymap and the setter come in later phases. pearl-default-view (defcustom) holds the legacy-mode default: nil for my open issues, or a local-view name. In accounts mode the per-account :default-view key wins instead, so pearl--resolve-account now carries :default-view in its context plist and pearl--resolve-default-view reads from the active account, falling back to the global only in legacy mode. pearl-open-default-view dispatches the resolved default: nil runs pearl-list-issues, a known local-view name runs pearl-run-local-view, and a name that's since been deleted falls back to pearl-list-issues with a message instead of erroring. pearl-list-issues keeps its contract โ€” it's still literal my-open-issues, the keymap rebind is phase 3. Nine tests cover the resolver in both modes and the three dispatch paths. Suite 792 to 801, compile and lint clean.
* feat(keys): dedicated views group, de-overloaded fetch, account bindingCraig Jennings2026-06-022-23/+65
| | | | | | | | | | The C-; L keymap and the transient menu had drifted into two different letter schemes for the same commands, the fetch sub-map carried both issue-fetching and view-movement, and pearl-switch-account was bound nowhere. Give views their own group under v: l/L run local/Linear, c/e/k create/edit/delete, u/U publish local/current, d save a Linear view locally. Fetch (f) is now issue sources only. A new workspace group (w) binds switch-account plus the setup commands, which previously lived only in the transient. v D is reserved, unbound, for the not-yet-built set-default-view. The transient already had an issue-only fetch group and a dedicated views group, so its changes are additive: switch-account and copy-issue-url were both missing from the menu and are added, and save/save-all/edit-description take s/S/d to match the keymap's hot keys. Matching those three cascades a few view and edit letters because the transient is a flat keyspace where every command needs a unique key. Suite 789 to 792, compile and lint clean.
* feat(filter): multi-select states in the interactive builderCraig Jennings2026-06-011-0/+40
| | | | | | I made the builder's State prompt a completing-read-multiple, the way the Labels prompt already is, so you can pick several workflow states for one filter. The selection maps the way the spec describes: pick one and :state stays a scalar string, pick several and it becomes a list (state.name.in), pick none and there's no state constraint. The "[ Any. ]" sentinel is filtered out of the result like it is for labels. Phase 2 of docs/multi-state-filter-spec.org. A stubbed-builder test covers the one/many/none mapping. Suite, compile, and lint green.
* feat(filter): :state accepts a list of state namesCraig Jennings2026-06-012-0/+44
| | | | | | I made :state polymorphic, mirroring :state-type. A string still compiles to state.name.eq. A list now compiles to state.name.in, so (:state ("Todo" "In Review")) matches issues in either state. The reverse-compile inverts state.name.in back to a scalar (one name) or a list (several), so copy-down no longer refuses a Linear view filtering on a set of named states. The validator accepts a string or a list of non-empty names, the same shape it already enforces for :labels, and a list-valued :state keeps its precedence over :state-type and :open. Phase 1 of docs/multi-state-filter-spec.org. Suite, compile, and lint green.
* test(views): source-level naming regression locking out "saved query"Craig Jennings2026-06-011-0/+10
| | | | | | | | I added a test that reads pearl.el and asserts the user-facing "saved query" phrase appears nowhere, so the rename can't silently regress. Internal helpers keep the hyphenated saved-query symbol fragment, which a user never sees. The banned thing is the two-word user-facing phrase in any prompt, message, transient label, or docstring. This is the cross-cutting net over Phase 8. The per-phase tests already cover the account guard, cross-store dedup and dispatch, reverse-compile round-trips and refusals, and the metadata-preserving writer. The live create, edit, run, publish, save-locally, refuse, and delete end-to-end needs a real Linear workspace and is filed as a manual-verify checklist. Suite at 777 green, compile and lint clean.
* feat(views): save a Linear view locally via the reverse-compile (copy down)Craig Jennings2026-06-013-2/+233
| | | | | | | | | | I implemented copy-down, the one direction that needed real engine work. A live probe showed CustomView.filterData is readable but stored as Linear's and/or view-filter tree, not the flat IssueFilter Pearl emits, so pearl--reverse-compile-issue-filter is a normalize-then-match: it flattens the top-level and, unwraps single-branch and/or, reads a one-element in as a scalar, then matches each conjunct to one authoring key. Every helper returns (ok . plist) or (refuse . reason). The contract follows the spec's dimension table: distinct :state and :state-type, canonical numeric :priority, :open t mapped from the exact open-state nin while generic nin refuses, and a multi-value in on any singular key refuses rather than silently narrow the view. Anything outside Pearl's AND-only model refuses with a structured reason that names the construct. pearl-save-linear-view-locally lists favorited Linear views, fetches the chosen view's filterData through a small async helper, reverse-compiles it, and saves a forked local view through pearl--save-local-view: no tracking link, :copied-from-view-id provenance only, active account stamped. On a refusal it points the user at pearl-run-linear-view; on success it states the fork's independence. Bound at f d (down) and the transient Views column. Phase 6 of the views spec. 30 tests cover per-dimension round-trips (build->encode->read bridge), normalize-from-real-JSON trees, the full refusal set, and the command-side. Suite, compile, and lint are green.
* feat(views): guard publish and delete against cross-account local viewsCraig Jennings2026-06-011-0/+33
| | | | | | | | I extended the account guard from edit and run to the two remote-mutating commands. pearl-publish-local-view now refuses a local view tagged to another account before any customViewCreate or customViewUpdate, and pearl-delete-local-view refuses before the confirm prompt or any customViewDelete. Publishing or deleting under the wrong account would resolve ids against the wrong workspace or target the wrong remote, so the guard fires at the command boundary, before Linear is touched. publish-current-view inherits the guard by delegating to publish-local-view. This completes the round-6 finding that one guard should cover every read and mutate of a local-view entry, not only edit and copy. Phase 5 of the views spec. Tests confirm both commands error on a cross-account entry and make no Linear call. Suite, compile, and lint are green.
* feat(views): rework the picker for local/Linear labels, dedup, and local โ†ตCraig Jennings2026-06-011-22/+61
| | | | | | | | | | | | dispatch I reworked pearl--pick-source-candidates for the three Phase 4 behaviors. Labels now read [local] for a private local view, [local -> Linear:SCOPE] for a published one, [linear] for a Linear view favorite, and [KIND] for a non-view favorite, so the label itself shows where a view lives. Dispatch follows the source-of-truth rule (decision 16): every local view, tracked or not, dispatches as a :type filter and runs its local authoring filter, never the Linear mirror. A tracked entry carries its :linear-view-url so the rendered buffer can still open the mirror. Before this, a published local view ran server-side, which contradicted the editable-local model. Cross-store dedup: a Linear view favorite whose customView id matches a tracked local view's :linear-view-id is dropped, so the editable local entry is the one shown. A local and a Linear view that merely share a name stay distinct. Phase 4 of the views spec. Tests cover the labels, the local-filter dispatch, and the dedup and same-name cases. Suite, compile, and lint are green.
* feat(views): reshape the keymap and transient around the view nounCraig Jennings2026-06-011-5/+7
| | | | | | | | I put the view operations under the verb a user would reach for. Create, edit, and delete local views are c v, e v, and k v. The fetch group gains run-local-view at f l and keeps run-linear-view at f v, with publish at f u and save-locally reserved at f d. Since f p already runs issues by project, publish and save-locally take u and d, which read as up and down and mirror the copy-up and copy-down lifecycle. The transient now has a Views column grouping create, edit, run-local, run-linear, publish, and delete, separate from the source-fetching commands. I updated the spec's keymap to the implemented letters so the two stay in sync. Phase 3 of the views spec. Keymap tests assert the new bindings. Suite, compile, and lint are green.
* feat(views): add create and edit local views with a metadata-preserving writerCraig Jennings2026-06-012-7/+178
| | | | | | | | | | I added pearl-create-local-view and pearl-edit-local-view, plus the writer and guards the spec calls for. pearl--save-local-view replaces the old rebuild-from-scratch writer. It copies an existing entry and updates only the filter, sort, and order, so a published view keeps its :linear-view-* tracking link and a copied-down view keeps its provenance. A new entry gets the active account stamped under pearl-accounts. pearl--require-local-view-account is the one guard every read or mutate command shares, and pearl-run-local-view now calls it instead of an inline check. pearl--resolve-local-view-name carries the Replace/Rename/Cancel collision policy for create, edit-rename, and copy-down. The ad-hoc builder's save offer routes through the preserving writer and reads "save this as a local view". Edit re-runs the builder when you choose to rebuild and otherwise keeps the stored filter. Full per-dimension pre-seeding of the builder is deferred, since it needs the id-to-name reverse resolution that copy-down's reverse-compile introduces. I filed it as a follow-up. Phase 2 of the views spec. Tests cover metadata preservation, the account guard, the collision policy, and the metadata-preserving rename. Suite, compile, and lint are green.
* refactor: rename saved-query surface to local-view vocabularyCraig Jennings2026-06-0112-129/+203
| | | | | | | | I renamed Pearl's "saved query" surface to the local/Linear view vocabulary, with no obsolete aliases, since Pearl has no users to protect. The defcustom pearl-saved-queries is now pearl-local-views. The commands pearl-run-saved-query, pearl-delete-saved-query, pearl-sync-saved-query-to-linear, pearl-run-view, and pearl-publish-current-source become pearl-run-local-view, pearl-delete-local-view, pearl-publish-local-view, pearl-run-linear-view, and pearl-publish-current-view. User-facing prompts, messages, and docstrings drop "saved query" for "local view", and the publish command reads "publish" instead of "sync". Internal GraphQL and helper names keep their "query" terms, which a user never sees. A naming-regression test asserts the new symbols exist, the old ones are gone rather than aliased, and no public command exposes "query". Phase 1 of docs/local-and-linear-views-spec.org. No behavior change. Suite, compile, and lint are green.
* fix(accounts): pearl-check-setup reports the active account, not the legacy keyCraig Jennings2026-05-311-0/+34
| | | | The setup check gated on pearl-api-key, which is normally unset once pearl-accounts is configured, so under multi-account it wrongly reported "API key is not set" and skipped the connection test even when the active account resolved a key fine. Under accounts mode it now resolves the active account's context, names it, and runs the test. An unusable setup (no default, missing key) reports the reason instead of signaling. Legacy single-account behavior is unchanged. The resolved key is never printed.
* feat(accounts): multi-account support with testsCraig Jennings2026-05-311-0/+481
| | | | | | | | | | | | I added a named-account layer for working more than one Linear workspace from one Emacs. Before this, everything that identified a workspace was a single global: pearl-api-key, pearl-graphql-url, pearl-org-file-path, and the lookup caches. Nothing stopped a work command from running under personal credentials or a work fetch from landing in the personal file, and switching accounts meant re-customizing the key, team, and file by hand and clearing the cache. pearl-accounts maps a name to a per-workspace plist (credential source, org file, default team, optional endpoint), and pearl-switch-account makes one active. Account state flows through an explicit context rather than mutating globals. Every request snapshots its account at dispatch through pearl--graphql-request-async and re-establishes it around the callbacks. A switch mid-fetch can't bleed into a request already in flight: the result finishes into the account it was dispatched under. I centralized this in the one request primitive, so the leak surface is a single function instead of every call site. Rendered files carry a #+LINEAR-ACCOUNT marker, and a buffer guard refuses a command run from one account's file while another is active, naming both, so a work edit can't push under personal credentials. An unmarked legacy file lets reads and refreshes through and acquires its marker on the first refresh. Mutations wait until then. Credentials resolve through auth-source, an env var, or an inline literal, and a resolved key is never persisted through Customize or logged. The active account shows in the mode line. A saved query can carry an :account so it refuses to run under the wrong workspace before any lookup. With pearl-accounts unset, everything behaves exactly as before, off the legacy globals.
* fix(merge): land the type-nil marker fix that should have been in eb56295Craig Jennings2026-05-281-14/+16
| | | | | | | | | | | | The previous commit (eb56295) shipped the buggy pre-fix version of pearl--merge-issues-into-buffer along with a too-weak test. The intended fix and the strengthened test were sitting uncommitted in the working tree when I ran git commit, because I forgot to re-stage pearl.el and tests/test-pearl-merge.el after addressing the critical correctness finding from pre-commit review. The test that landed asserts each touched marker leads to some LINEAR-ID heading, which passes against the buggy code (the markers point at the wrong heading, but it's still a valid heading). This commit is the fix and the real test, which together honor what eb56295's message described. Code change: pearl--merge-issues-into-buffer captures a type-nil copy of the alist marker BEFORE calling pearl--replace-issue-subtree-at-point, instead of pushing the original type-t marker after the replace. The alist marker is insertion-type t (it needs to advance past replaces of EARLIER subtrees so it stays anchored to its own heading), so reusing it after the current subtree's delete-then-insert captures the post-advance position, which is the NEXT subtree's heading. A type-nil copy made before the replace stays anchored at the deletion start and lands on the new heading after the re-insert. Test change: the returns-touched-markers test now resolves each marker to its heading's LINEAR-ID and asserts the set matches {a, b, c} exactly (for 2 updated + 1 added), instead of just checking each marker reaches some heading. With the buggy code the IDs would resolve to {b, c, c} (a's marker advanced to b's heading, b's marker advanced to c's added heading). The strengthened assertion fails on the bug. All 674 ert tests pass. make compile and make lint are clean.
* feat(merge): preserve the user's view across a merge refreshCraig Jennings2026-05-281-0/+92
| | | | | | | | | | | | A merge refresh (the "merge keeping edits" choice on a dirty buffer, or pearl-refresh-current-view on a clean one) used to call pearl--restore-page-visibility at the tail, re-folding the whole buffer to its #+STARTUP overview. That collapsed every subtree the user had expanded, including the comment they were just editing, and disturbed point. The edit-then-merge flow felt jarring. The fix: pearl--merge-issues-into-buffer now returns a marker for every subtree it re-rendered or appended, alongside the existing counts (:touched-markers (M1 M2 ...)). The merge call sites fold just those subtrees via a new pearl--fold-touched-subtrees helper. Kept (locally-edited) and untouched subtrees stay exactly as the user had them, and point is preserved because the localized fold doesn't touch the rest of the page. The full-rebuild paths (Branch A no-buffer, Branch B clean-buffer replace, Branch C-discard) still call pearl--restore-page-visibility. They re-render the whole buffer so the global re-fold is the right behavior. Pre-commit review caught a critical correctness bug in the first draft: the alist marker from pearl--issue-subtree-markers is insertion-type t (it needs to advance past replaces of EARLIER subtrees so it stays anchored to its own heading), and reusing it after pearl--replace-issue-subtree-at-point captured the post-advance position, which is the NEXT subtree's heading, so the fold collapsed the wrong subtree. The fix copies the marker as type-nil BEFORE the replace, so it stays anchored at the deletion start and lands on the new heading after the re-insert. I strengthened the marker test to verify the IDs at the touched markers match the expected set ({a, b, c} for 2 updated + 1 added), not just that they're some valid headings. The original test would have passed against the bug. Five new tests in test-pearl-merge.el cover: merge returns markers with the correct IDs at their headings, skipped (locally-edited) issues don't add to touched-markers, fold-subtree-at-marker hides body content while leaving the heading visible, fold-touched-subtrees is a no-op when pearl-fold-after-update is nil, and fold-subtree-at-marker tolerates a nil marker silently. All 674 ert tests pass. make compile and make lint are clean.
* refactor(render): drop the leading ๐Ÿ’ฌ glyph from the Comments headingCraig Jennings2026-05-283-13/+29
| | | | | | | | | | Pearl was rendering the Comments subtree heading as `*** ๐Ÿ’ฌ Comments [N/M]`. Craig flagged that it looks strange, and the renderer should work cleanly before it gets glyphed. I dropped the emoji prefix so the heading reads `*** Comments [N/M]`. Partly reverts 41a3396. The overlay-glyph idea (a content-aware display overlay on the leading stars) stays tracked as a separate task. I touched pearl--format-comments (the new-subtree literal) and pearl--append-comment-to-issue (both the new-subtree insert and a couple of stale docstring references). The append-locator regex stays tolerant of the legacy `๐Ÿ’ฌ Comments` heading and of the older pre-2026-05 `Comments ๐Ÿ’ฌ N/N` trailing-glyph layout. Buffers rendered in any of the three states still locate their Comments heading on append. I added one regression test (test-pearl-append-comment-locates-legacy-emoji-prefixed-heading) that seeds a `*** ๐Ÿ’ฌ Comments 1/1` heading and asserts append still finds it, bumps the count to 2/2, and doesn't add a second Comments heading. I updated assertions in test-pearl-comments.el, test-pearl-list-comments.el, and test-integration-acceptance.el to expect the no-emoji form. I also updated docstrings on pearl-fetch-comments-in-list, pearl-list-comments-count-cap, pearl--format-comments, pearl--bump-comments-count-marker, and pearl--comment-count-marker. The README's sample buffer block now matches what the renderer emits. All 669 ert tests pass. make compile and make lint are clean.
* refactor(view-sync): pick-scope returns (display . plist), drops rassoc lookupCraig Jennings2026-05-281-4/+79
| | | | | | | | | | pearl-sync-saved-query-to-linear used to rebuild the entire scope-candidates list a second time (full sort + 2N row allocations) just to rassoc the chosen scope plist back to its display string for the success message. The picker had thrown away the display string the user picked, so the caller had no choice but to re-derive it. I changed pearl--sync-saved-query-pick-scope to return (DISPLAY . PLIST) instead of just PLIST. The caller now reads scope-label from (car scope-pair) and scope from (cdr scope-pair). The rassoc is gone, and so is the second pearl--sync-scope-candidates call. To keep the re-sync path label-consistent with the picker (so a "Synced X (Team: Engineering, visible to the team)" message matches what the picker would have shown for the equivalent first-time sync), I extracted pearl--sync-scope-label as the single source of truth for the label format. The picker, the re-sync branch, and any future caller that needs to describe a scope all go through the helper. Personal scope always renders "[ Personal, only I see it ]" regardless of the shared flag (a Personal + shared combination is meaningless on Linear). Team scopes render "[ Team: NAME, visible to the team ]" or "[ Team: NAME, only I see it ]" per the flag. I added five tests covering the new behavior: scope-label for personal (ignores shared) and team (both shared and private), pick-scope returns the team pair, pick-scope returns the personal pair, pick-scope returns nil on cancel. All 668 ert tests pass. make compile and make lint are clean.
* fix(view-sync): refuse sync of a saved query whose filter is emptyCraig Jennings2026-05-281-0/+20
| | | | | | | | | | A saved query with no filter constraints compiled to nil via pearl--build-issue-filter. The sync command then passed (cons "filterData" nil) to customViewCreate, which JSON-encoded as filterData=null. Linear's CustomViewCreateInput.filterData expects a JSON object, so the API rejected with an opaque error message after the user had already clicked through the scope picker. I added an early refusal in pearl-sync-saved-query-to-linear: when filter-data is nil, signal a user-error naming what's wrong before the scope prompt fires. The message tells the user to add a constraint and re-sync rather than leaving them to puzzle out an opaque API rejection. I restructured the let* nesting to put the empty-filter check between the bindings (where filter-data is computed) and the scope prompt (where the user is asked to pick a destination). The scope binding moved out of the outer let* into an inner let so it doesn't evaluate until after the filter-data guard runs. Two tests cover the guard. The first-time-sync test (no :linear-view-id on the entry) asserts neither the scope prompt nor customViewCreate fires. The re-sync test (entry has :linear-view-id) asserts customViewUpdate doesn't fire either. The re-sync test guards against a future refactor accidentally hoisting the check into the create-only branch. All 663 ert tests pass. make compile and make lint are clean.
* refactor(view-sync): pearl--save-query-mark-synced preserves unknown plist keysCraig Jennings2026-05-281-0/+28
| | | | | | | | The prior implementation rebuilt the spec from a fixed key set (:filter, :sort, :order, plus the four :linear-view-* sync keys and the optional :linear-view-url) and silently dropped anything else. No keys outside that set live on a saved-query entry today, but the persistence layer was coupled to the current schema. A future :description, a user-added annotation, or any new pearl field would vanish on every re-sync. I switched to copy-then-plist-put: shallow-copy the existing spec, plist-put the four sync keys on top, and conditionally plist-put :linear-view-url when this call provided one. Arbitrary keys survive untouched. The URL-preservation behavior on a re-sync that omits view-url is unchanged: the prior :linear-view-url is already in the copied spec, so the when-view-url guard skips the put and leaves it in place. I added one regression test that seeds an entry with :description and :user-tag alongside the standard keys and asserts all of them survive a mark-synced call. All 661 ert tests pass. make compile and make lint are clean.
* feat(view-sync): pearl-publish-current-source convenience wrapperCraig Jennings2026-05-283-1/+69
| | | | | | | | | | This is Phase 5 of docs/saved-query-sync-spec.org (ยง New: pearl-publish-current-source). When I'm reading a local saved query rendered in pearl and decide it should be a Linear view, I now have a one-chord publish instead of the M-x round-trip through pearl-sync-saved-query-to-linear with the name lookup. The new command reads the active buffer's #+LINEAR-SOURCE header and dispatches to pearl-sync-saved-query-to-linear with the name pre-filled when the source is a :type filter that matches an entry in pearl-saved-queries. Other shapes refuse with a clear message: no source header at all, the source is already a :type view (no publish needed), the type isn't filter, the source has no usable name (transient ad-hoc filter, or whitespace-only name), or the recorded name doesn't match any local saved query. I bound the command under C-; L f P in pearl-fetch-map. The transient gets ("U" "upload current source") in the Fetch group because P is taken in the transient's flat namespace, and the verb-prefix shape revisit task tracks reshaping that. 7 new tests cover each refusal branch and the dispatch path, including a regression for the whitespace-only :name case the pre-commit review surfaced. The prior check used string-empty-p, which let " " fall through to the no-matching-saved-query branch with a visibly-blank name in the error message. string-blank-p routes it to the cleaner no-name branch. The keymap and transient menu tests gained coverage for the new binding. 660 ert tests total green. make compile and make lint clean.
* feat(view-sync): pearl-pick-source distinguishes [saved] / [saved โ†’ scope] โ†ตCraig Jennings2026-05-282-0/+151
| | | | | | | | | | | | | | | | | | | | | | / [view] This is Phase 4 of docs/saved-query-sync-spec.org (ยง Extended pearl-pick-source candidate label). The picker now reads where each saved query lives at a glance: - `[saved] Name`: local-only saved query, dispatches as :type filter (unchanged) - `[saved โ†’ Engineering] Name`: synced to a team-scoped Linear view - `[saved โ†’ Personal] Name`: synced as a personal Linear view - `[saved โ†’ ?] Name`: synced, but the team id no longer resolves (deleted/renamed on Linear) or the teams cache wasn't loaded - `[view] Name`: Linear-authored view favorite (unchanged) A synced entry's source plist gets :type view, so the existing view-dispatch branch in pearl-pick-source routes through pearl--query-view-async against the linked Linear view. Refresh and pagination honor whatever the view's filter is on Linear now, not the local authoring filter pearl built it from. I added pearl--saved-query-scope-label to resolve the scope string from (spec, teams) and extended pearl--pick-source-candidates with an optional teams argument. pearl-pick-source fetches pearl--all-teams lazily, only when at least one saved query carries :linear-view-id, and feeds the result in. An all-local configuration pays no extra network cost. While in the picker code I also closed a latent gap the review surfaced: pearl-open-current-view-in-linear was about to break on synced-saved-query sources because the source plist had no :url. I extended pearl--customview-create-async and -update-async to fetch `url` in the response, pearl--save-query-mark-synced to take an optional VIEW-URL arg and store it as :linear-view-url, and the picker source plist to surface :url when the entry has one. Entries synced before this commit have no stored URL. A re-sync repopulates it. Mark-synced preserves a prior URL when VIEW-URL is omitted, so a re-sync whose API response somehow lacks the URL doesn't erase the working one. 15 new tests across test-pearl-favorites.el (the picker labels for team-scope, Personal, unknown-team, no-teams-arg, URL surfaced + URL absent, mixed local/synced) and test-pearl-saved-query-sync.el (mark-synced stores URL when given, preserves prior URL on re-sync omission). The existing 4 pick-source tests stay green. They pass either no teams arg or nil, so the scope-resolution branch is dead in their cases. 653 ert tests total green. make compile and make lint clean. Lower-severity findings from the review filed as follow-ups: the `?` label conflates "unknown team" and "no teams data" (could be split). pearl--all-teams cache has no TTL so stale team names persist across renames. The lazy teams fetch reuses the "Fetching favorites..." progress message during its own roundtrip. Synced entries silently drop the local :sort/:order because Linear's view doesn't carry them in v1.