aboutsummaryrefslogtreecommitdiff
path: root/tests
Commit message (Collapse)AuthorAgeFilesLines
* fix(views): save a local view from any Custom View, not just favoritesCraig Jennings2026-06-162-7/+88
| | | | | | | | pearl-save-linear-view-locally only listed favorited Linear views, while pearl-run-linear-view lists every Custom View in the workspace. A view you could run often wasn't offered to save, so the copy-down silently produced nothing. The command now saves the current buffer's view when one is showing, and otherwise prompts over all Custom Views. Favorited custom views also came through nameless: the favorites query fetched customView id but not name, and Linear leaves the favorite's title blank for entity favorites. The picker showed a blank entry and the save dead-ended at a blank name. The query now selects customView name and pearl--normalize-favorite falls back to it, which also fixes blank names in pearl-pick-source. The success message now names the saved view and how to run it or set it as the default.
* fix(api): clear auth-source's negative cache on pearl-clear-cacheCraig Jennings2026-06-152-0/+18
| | | | A key added to authinfo read as missing for up to 2h because auth-source caches the negative lookup and `pearl-clear-cache` only cleared pearl's own caches. It now also calls `auth-source-forget-all-cached`, and the auth-source miss tells the user to run `M-x pearl-clear-cache`. This is the 2026-06-01 live incident, where a freshly-synced Linear key kept erroring as unset.
* fix(api): signal missing-key as user-error with mode-aware adviceCraig Jennings2026-06-152-0/+26
| | | | A missing API key raised a raw `error`, so it backtraced under `debug-on-error`, and the stale advice pointed at customizing `pearl-api-key`. Now `pearl--headers` signals a `user-error` and branches the advice by mode: accounts mode points at `pearl-accounts` and `pearl-switch-account`, legacy mode at `pearl-api-key` and `pearl-load-api-key-from-env`. The credential-source resolver's three failure paths become `user-error` too, since a bad credential is a config problem, not a bug.
* style(render): space the account name from Pearl in the lighterCraig Jennings2026-06-072-3/+3
|
* feat(render): highlight unsaved comments and update the cues liveCraig Jennings2026-06-071-0/+60
| | | | | | | | Completes the modified-ticket indicator (docs/modified-ticket-indicator-spec.org) on top of the field highlight and count. A changed comment now gets its own highlight by ownership: your own comment (pushable) takes pearl-modified-highlight and bubbles up to the ticket heading; a non-own comment, which save skips, takes the muted pearl-modified-local; a comment seen before the viewer resolves takes the neutral pearl-modified-unknown until classification lands. The viewer is resolved only when a comment is actually dirty, so opening or editing a clean or field-only buffer makes no network call. The cues update live: a buffer-local after-change hook debounces a redecorate on an idle timer (pearl-modified-idle-delay), so an edit shows within a fraction of a second instead of waiting for a save or refresh. The timer is cancelled when pearl-mode turns off or the buffer is killed. One pearl--redecorate-modified now drives the field highlights, the comment classification, and the count from a single scan, called on mode enable, fetch/refresh, and the idle tick.
* feat(render): show unsaved tickets with a field highlight and a countCraig Jennings2026-06-071-0/+175
| | | | | | | | | | Under the deferred save model a ticket can carry unsaved local edits with nothing on screen saying so. These are the first two phases of the modified-ticket indicator (docs/modified-ticket-indicator-spec.org). A pushable-dirty ticket gets a background highlight (pearl-modified-highlight, inheriting the theme's diff-changed) on its heading line, and on the description body when the description changed. The mode-line lighter gains a count rendered with the ticket glyph: "Pearl[work] 3 🎫 changed". Both ride the existing dirty scan and redecoration pass, clear on the next pass after a save, and never touch buffer text. The count is tickets, not fields: each ticket counts once however many of its fields changed, and only when it has pushable work (a dirty field, or an own comment once the viewer resolves), so it matches what save-all would push. Per-comment highlighting and the live after-change trigger come next. diff-changed was picked over diff-refine-changed after a cross-theme legibility survey; pearl now requires diff-mode for that face.
* fix(keymap): label the issue create/delete keys 'ticket' to match the t chordCraig Jennings2026-06-071-1/+1
|
* feat(render): name the active account in the buffer titleCraig Jennings2026-06-061-0/+13
| | | | With two workspaces, the preamble summary read "Pearl - <view> ..." for both, so you couldn't tell from the title which account a buffer belonged to. It now reads "Pearl - <account> - <view> · <count> · <time>", pulling the account from the buffer's #+LINEAR-ACCOUNT. A legacy buffer with no account stamp drops the segment and reads as before.
* feat(keys): add sort and grouping to the transient menuCraig Jennings2026-06-061-1/+5
| | | | | | The view-shaping commands were keymap-only. Now they're in the menu's Views group too: 5 sorts, G groups, R reverses the order. The mnemonic letters were already taken menu-wide (s and S save, g and r refresh), so sort took the digit 5 and group/reverse took the free capitals. The menu coverage test now requires all three, and the README sort/grouping notes point at the menu keys.
* fix(views): order group sections by Linear's board orderCraig Jennings2026-06-062-8/+68
| | | | | | | | A grouped view rendered its sections in first-appearance order: whatever order issues came back from the API. So the columns came out scrambled instead of following Linear's board order (Icebox, then In Progress, then Done). Org's own sort couldn't fix it either, since the buffer carried nothing to sort the sections on. Group sections now order by a per-dimension key. workflowState orders by state type rank (triage, backlog, unstarted, started, completed, canceled), with the state's position breaking ties within a type, so it matches the board. priority orders Urgent down to None last. project, assignee, and cycle have no board order, so they sort alphabetically. One shared helper feeds both the render path and the interactive regroup, so they can't drift. For the regroup to order without a refetch, the state position has to be in the buffer, so the issue drawer now carries LINEAR-STATE-POSITION (fetched alongside the name and type pearl already pulled). A buffer rendered before this change has no position field and falls back to ordering by type rank alone until its next fetch.
* feat(keys): bind sort and grouping under the views prefixCraig Jennings2026-06-061-0/+12
| | | | | | The interactive view-shaping commands shipped M-x-only. I bound them in the views sub-map so they're reachable from the keyboard: C-; L v s sorts, C-; L v g groups, C-; L v r reverses the sort order, with matching which-key labels. They sit in the views group because there's no dedicated view-shaping group. Opening one means reopening the whole keymap-prefix question that's already parked. So they reshape the active buffer from under a "views" prefix that otherwise manages saved views, a slight category stretch called out in the map's docstring. The transient menu still omits both, pending the same keymap-shape pass.
* feat(views): route grouping consumers through the effective-grouping resolverCraig Jennings2026-06-061-0/+27
| | | | | | Phase 4 of the interactive-grouping spec: the integration that makes an interactive grouping survive a refresh. The full render (pearl--build-org-content) and both merge-refresh paths (the dirty-merge branch and pearl--merge-query-result) read the source's grouping straight from :group. I pointed all three at pearl--effective-grouping instead, so a buffer the user grouped by hand redraws the same way rather than snapping back to the view's server grouping. The swap is backward-compatible: with no :client-group set, the resolver returns :group, so a server-grouped view renders exactly as before. 2 render tests lock both directions: a :client-group overrides the server :group, and a server-only view still groups by :group.
* feat(views): pearl-set-grouping commandCraig Jennings2026-06-061-0/+41
| | | | | | Phase 3 of the interactive-grouping spec: the command that ties phases 1 and 2 together. pearl-set-grouping completing-reads a dimension (status/project/assignee/priority/none), coerces and validates it, regroups the buffer, then persists the choice on the source. M-x only for now, like pearl-set-sort. The transient and keymap entries come later. pearl--apply-grouping is the non-interactive core, shaped like pearl--apply-sort. On a successful regroup it re-folds the page and writes :client-group into the header. On an empty buffer it reports no-issues and writes nothing, so a failed grouping never advertises a layout the buffer doesn't show. none ungroups and clears the override. 4 tests cover persistence, the none-clears path, the re-fold, and the no-issues skip.
* 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.