aboutsummaryrefslogtreecommitdiff
path: root/tests
Commit message (Collapse)AuthorAgeFilesLines
* 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.
* feat(view-sync): extend pearl-delete-saved-query with delete-on-Linear promptCraig Jennings2026-05-281-0/+128
| | | | | | | | | | Phase 3 of docs/saved-query-sync-spec.org. pearl-delete-saved-query on a synced entry (one carrying :linear-view-id) now asks a second question after the local-delete confirmation: also delete the linked Linear view? Yes calls customViewDelete, no unlinks only and leaves the Linear view in place. Local-only entries take the unchanged single-prompt path. If customViewDelete fails on the API side, a fallback yes-or-no prompt asks whether to drop the local entry anyway. Accepting orphans the Linear view, and the success message names the view id explicitly so the user can clean it up by hand. The same view-id-in-message pattern fires on the timeout branch, since a timeout doesn't tell us whether the delete actually completed server-side. I added pearl--customview-delete-async (the mutation, parallel to the create and update helpers from Phase 2) and pearl--delete-saved-query-local (factors the cl-remove + customize-save-variable that both the local-only and the synced-unlink branches need). The new pearl--delete-saved-query-do-linear-delete carries the API path so the top-level command stays readable. I added 7 tests in test-pearl-saved-query-sync.el covering the customViewDelete success and failure parses, the synced yes/yes path (API call fires + local removed), the synced yes/no path (unlink only, no API call), the API-failure-then-delete-anyway path (orphan message names the view id), the API-failure-then-keep path (asserts the API call fired so a refactor can't silently route through the unlink branch), and the local-only entry unchanged path. The existing pearl-delete-saved-query tests in test-pearl-adhoc.el stay green. Their fixtures don't carry :linear-view-id, so they take the local-only branch. 638 tests total green. make compile and make lint clean.
* feat(view-sync): pearl-sync-saved-query-to-linear + transient entryCraig Jennings2026-05-283-1/+383
| | | | | | | | | | | | | | | | | | | | | pearl-sync-saved-query-to-linear promotes a local pearl-saved-queries entry to a Linear Custom View via customViewCreate, or updates an already-synced entry in place via customViewUpdate. This is Phase 2 of docs/saved-query-sync-spec.org. Re-syncing keeps the stored scope. First-time sync prompts for the destination (team + visibility), with each candidate spelling out the complete end-state. Same-name collisions in the chosen scope prompt Replace / Rename / Cancel. A successful sync extends the entry with :linear-view-id, :linear-view-team-id, :linear-view-shared, :linear-view-synced-at. If the customize-save-variable persist fails after a successful API call, the message names the orphan view's id explicitly so the user can re-sync with Replace to reconcile. I bound the command under C-; L f S in pearl-fetch-map and added ("S" "sync saved query") to the transient's Fetch group. filterData is the existing pearl--build-issue-filter output verbatim. The 2026-05-28 live probe confirmed the shape passes through customView.filterData with no translation needed. Four related fixes ride along: - Extended pearl-get-teams-async to fetch `key' alongside `id name'. It shares pearl--cache-teams with pearl--all-teams, so an async-first populate would otherwise leave the cache without `key' and silently break filter-team lookups. - Normalized the stored :linear-view-shared flag to a strict boolean at the re-sync read site. `:json-false' is truthy in Elisp, so an unnormalized stored false would flip the view to team-shared on re-sync without asking. - pearl--sync-saved-query-await now returns `(:success nil :timeout t)' on a wait-for elapse, so the caller can distinguish a timeout from a Linear-side rejection and tell the user the request may still complete server-side. - The success path now emits a single message via pearl--sync-record-or-orphan-error. The prior code clobbered the orphan-recoverable view-id with a second message. I hoisted the prompt-sentinel block (pearl--filter-cancel etc.) above its first user. The prior order produced a forward-reference compile error the moment any other change touched the file. I added 31 tests in test-pearl-saved-query-sync.el covering the scope candidates, the customViewCreate/Update parsing, the entry persistence (including the :json-false overwrite case), the orphan-id error path, the timeout sentinel, and the get-teams-async cache parity. The keymap and menu tests gained coverage for the new binding. 631 ert tests total green. make compile and make lint clean. Lower-severity findings filed as follow-ups: stale pearl--cache-views across a schema upgrade, empty filter encoding to null filterData, the pearl--save-query-mark-synced rebuild dropping unknown plist keys, and the scope-label rassoc rebuilding the candidate list to look up a string the picker already had.
* feat(prompts): split sentinel + default-yes for safe yes/noCraig Jennings2026-05-282-35/+87
| | | | | | | | | | Two UX refinements on the just-shipped prompt surface, both following the principle "the prompt should describe what it's doing and the default should be the most-common choice." First, the sentinel that was uniformly "[ None. ]" really meant two different things at two kinds of prompt, and the label only matched one of them. Filter-dimension prompts (team, state, project, labels, assignee) treat picking the sentinel as "no constraint on this dimension." Every value matches, which is *any*, not *none*. The saved-query prompts (delete, run) treat picking the sentinel as "don't act." That one is *cancel*, not *none* or *any*. Renamed accordingly: `pearl--filter-any` ("[ Any. ]") for the five filter dimensions and `pearl--filter-cancel` ("[ Cancel. ]") for the two saved-query prompts. The generic helper became `pearl--with-sentinel SENTINEL CANDIDATES` so each call site picks the label that fits its case. The predicate became `pearl--filter-sentinel-value-p` (recognizes either sentinel or empty/nil) so the cancellation logic is unchanged. Second, three non-destructive yes/no prompts ("Open issues only?", "Save this filter locally...", "Save N fields across M issues?") moved from `y-or-n-p` to a new `pearl--read-yes-no` helper. The helper renders a completing-read over ("yes" "no") with the most-common choice as the default and topmost candidate, so RET takes it without typing. Default is "yes" for all three (each is a do-the-thing-I-asked confirmation), but the helper takes a DEFAULT arg so a future prompt where "no" is more common can opt in. The destructive prompts (delete issue, delete saved query, delete comment) stay as `yes-or-no-p`. Typing "yes" there is a deliberate safety affordance, not friction worth removing. Tests cover the sentinel-value predicate across both sentinels + empty/nil + real values, the `pearl--with-sentinel` helper, the `pearl--read-yes-no` t/nil return and default-ordering behavior, and the three save-test stubs swapped to mock the new helper.
* feat(saved): add pearl-delete-saved-query and a sentinel cancel for runCraig Jennings2026-05-282-1/+63
| | | | | | `pearl-saved-queries' was write-only from a user's perspective. Save was bound to the builder, run was bound to a key, but removing a stale entry meant `M-x customize-variable RET pearl-saved-queries RET` and a manual delete from the Customize buffer. `pearl-delete-saved-query` under `C-; L k q` (and `q` in the transient's Delete group) closes that gap: completes over the saved query names with `[ None. ]` at the top as a cancel, confirms, removes the entry, and persists via `customize-save-variable`. Linear is untouched. Saved queries are local. Pushing them up to Linear as custom views is a separate spec. While in the same path, `pearl-run-saved-query` got the same sentinel cancel. Both prompts use the keep-order completion table so `[ None. ]` stays at the top regardless of the user's completion sorter. Both error cleanly when `pearl-saved-queries` is empty rather than offering an empty picker. Six tests cover the new command (delete-on-yes, no-at-confirm, sentinel-cancels, unknown-name-errors, empty-list-errors) plus run's sentinel cancel.
* fix(filter): pin builder candidate order so [ None. ] stays at the topCraig Jennings2026-05-281-0/+16
| | | | | | Wrapping each builder prompt with `pearl--with-none` put the sentinel first in the candidate list, but the completion framework's default sort (vertico-sort-history-length-alpha, prescient, ivy's sorter, etc.) re-sorted alphabetically and any real candidate starting with a letter before "[" pushed the sentinel down. Linear's "Software Engineering" outsorted "[ None. ]", so the new pattern failed at the very first prompt. The standard Emacs hook for "I sorted these on purpose" is a completion table that returns `(metadata (display-sort-function . identity))` for the `metadata` action. The new `pearl--completion-table-keep-order` helper does exactly that, and the five builder prompts (team, state, project, labels, assignee) now wrap their candidate lists through it. The framework keeps its default sort everywhere else. Only these five prompts opt out, in the place where author-order is meaningful.
* feat(filter): show "[ None. ]" as the topmost option in builder promptsCraig Jennings2026-05-281-0/+29
| | | | | | | | The builder's state / project / team / labels / assignee prompts each relied on the "empty input means no constraint" convention, with a parenthetical hint in the prompt label ("Team (empty for any):"). The convention is invisible, has to be remembered, and reads as a non-choice rather than a choice. Now every prompt carries `pearl--filter-none` ("[ None. ]") as the first candidate and uses `require-match` so the user picks from the list. Selecting the sentinel is the same logical opt-out as empty input was, but the choice is on screen rather than in the user's head. Two small helpers (`pearl--with-none`, `pearl--filter-none-value-p`) and one defconst keep the sentinel string in one place. The prompt labels lost their parenthetical hints since the affordance is now in the candidate list. Five unit tests cover the sentinel predicate and the list helper across the normal / boundary / "real value" cases. The pattern generalizes well past the builder: any time pearl prompts the user to pick between a value and "no value," the no-value option belongs in the list, not hidden behind empty input.
* refactor(preamble): tighten buffer affordance comments to keymap formCraig Jennings2026-05-271-2/+3
| | | | The four-line preamble enumerated `M-x pearl-save-issue` / `M-x pearl-create-comment` / etc., even though pearl-mode binds the whole verb-prefix keymap under `C-; L` and that's what users actually type after the first session. Replaced with two lines: one for inline edits + save, one for the verb-prefix groups (edit, refresh, menu, pick-source). Reads as a key reference rather than a command list. The build-org-content test now matches the affordance line on "save" instead of the literal `pearl-save-issue` so the next rephrase doesn't trip it.
* feat(sources): favorites, views, and filters via pick-sourceCraig Jennings2026-05-275-5/+301
| | | | | | | | | | Pearl now treats Linear sources (favorites, custom views, ad-hoc filters, saved queries) as one surface. `pearl-pick-source` is the unified picker: it lists every Linear favorite alongside every locally saved query, and dispatches by kind. View / project / cycle / label / user favorites resolve to runnable filter or view sources; issue / document / dashboard favorites open in the browser since they aren't lists. The filter compiler grows id-based forms (`:label-id` and `:assignee-id`), so favorites that name a Linear entity (a user, a label) compile to rename-proof queries rather than fragile name matching. The ad-hoc builder gets a matching member-assignee option ("me / member / any"), so a one-off "what's a teammate working on?" doesn't require building a saved query first. Two pre-existing builder bugs surfaced during verify and got fixed in the same pass. The builder previously passed display names straight to the compiler, but `:team` compiles to `team.key.eq` and `:project` to `project.id.eq`, so Linear rejected every builder run that pinned a team or project. Now the builder resolves the picked name to its key / id before assembling. And `pearl-list-issues-by-project` previously injected `:assignee :me` into the filter, hiding every teammate's work in the project. It now returns the whole project. The save prompt on the builder was rewritten so the local-only nature is unambiguous, and the README grew a Sources section plus a keymap-table entry for `f s`.
* refactor: verb-align command names and prefixes, use issue not ticketCraig Jennings2026-05-2710-53/+53
| | | | | | The keybinding prefixes now each name an action verb: f fetch, e edit, c create, k delete, o open, y copy. The url group is gone, its actions split into open and copy, and the direct lowercase c (new comment) is dropped so c can head the create group. Two commands carried non-verb names, so I renamed them: pearl-new-issue is now pearl-create-issue and pearl-add-comment is now pearl-create-comment. The old sync helper that already held the pearl-create-issue name moved to the private pearl--create-issue, where it belongs as internal plumbing. I also swept "ticket" to "issue" across the labels, transient, docstrings, and README. Linear's own term is "issue" and the buffer mirrors Linear, so the command names already said "issue" while the labels said "ticket". Now they agree. The transient's per-entry keys still trail the keymap verbs, which stays its own task.
* feat(compose): open compose and conflict buffers in a configurable side windowCraig Jennings2026-05-271-0/+23
| | | | The compose buffers (edit-description, add-comment) and the smerge conflict buffer popped wherever display-buffer happened to put them. Now they open in a side window, defaulting to the bottom at 30% of the frame, with two defcustoms to control it: pearl-compose-window-side (bottom / top / left / right) and pearl-compose-window-size (a fraction of the frame, or an absolute line/column count). pearl--compose-display-action builds the display-buffer-in-side-window action and both pop sites pass it through pop-to-buffer, so Pearl always applies the side window over any display-buffer-alist entry for these buffers.
* fix(save): detect comment edits with a rendered-Org hash, not the md round-tripCraig Jennings2026-05-271-0/+52
| | | | | | A comment whose markdown doesn't survive md->org->md (a heading, single-asterisk italics) was flagged changed on every save, because the dirty scan compared org-to-md of the rendered body against the stored markdown hash. When the comment was authored by someone else, that surfaced as "1 read-only comment skipped" on every save even though nothing had been edited. Descriptions already dodge this: 9424b84 gave them LINEAR-DESC-ORG-SHA256 and compared Org-to-Org. I did the same for comments. pearl--format-comment now writes LINEAR-COMMENT-ORG-SHA256 (the rendered body hash), and a new pearl--comment-dirty-p compares the current body against it, falling back to the old markdown hash for comments rendered before the baseline existed. Both detection sites use it: the save scan and the edit-current-comment no-op check. The markdown hash stays for the remote-conflict gate.
* feat(labels): render Linear labels as Org tags on issue headingsCraig Jennings2026-05-272-2/+195
| | | | | | | | An issue's Linear labels now appear as Org tags on its heading (** TODO [#B] Title :bug:backend:), so the org-native gestures work: filter by tag, build a tag agenda, sparse-tree on :bug:. Before this, labels lived only in the :LINEAR-LABELS: drawer, which Org's tag machinery can't see. pearl--label-name-to-tag slugifies a label name to a tag: downcase, non-[[:alnum:]_] runs become a single underscore, Unicode letters preserved. pearl--label-tags builds the deduplicated set, first occurrence winning on a collision. The renderer appends them, and pearl-edit-labels rewrites them through pearl--set-heading-label-tags after updating the drawer. This is render-only. The :LINEAR-LABELS: drawer stays the source of truth, hand-edited heading tags are ignored by save and the dirty scan and get rewritten from Linear on the next change or fetch, and the way to change labels is still pearl-edit-labels. Bidirectional tag editing (heading tags back to Linear) is out of scope.
* feat(commands): add pearl-copy-issue-url, bound at u cCraig Jennings2026-05-272-4/+27
| | | | I added pearl-copy-issue-url. It copies the Linear URL of the issue at point to the kill ring and the system clipboard, reading the stored LINEAR-URL drawer property so there's no network call, and works from anywhere inside the issue subtree. kill-new carries it to the clipboard through interprogram-cut-function, so nothing shells out to a clipboard tool. Bound at u c in the url group, beside u o (open in browser).
* feat(keys): add pearl-mode and complete the command keymapCraig Jennings2026-05-271-38/+115
| | | | | | | | Pearl is now drivable entirely from the keyboard. pearl-mode turns on automatically in any buffer Pearl renders, detected by the #+LINEAR-SOURCE header on org-mode-hook, and binds the command map under pearl-keymap-prefix (default "C-; L"), so the keys are live without any global setup. I filled out pearl-prefix-map so every day-to-day command is reachable. The hot-path commands bind directly under the prefix (list, refresh view, refresh issue, save, edit description, new comment). The rest sit in category sub-maps (fetch, edit, new, delete, url). The common ones appear in both places, so edit-description is the direct d and also e d. Delete moved from d to k so d is the direct edit key, and the old save sub-map is gone now that save is the direct s and S. The org-mode-hook is registered at load rather than through an autoload cookie. An autoloaded hook would fire before pearl loaded, calling a void function, and would pull the whole package into every org buffer the user opens. Freshly fetched buffers also enable the mode directly in the writer.
* feat(render): render issue titles and the view name verbatimCraig Jennings2026-05-274-8/+23
| | | | | | The buffer should mirror Linear: a value on the page appears exactly as Linear stores it, so the heading title matches what you'd see opening the issue in Linear itself. I flipped pearl-title-case-headings to default nil so titles render verbatim. I also stopped running the view name in the #+title through the title-caser, which was coupled to the same flag. The smart-title-case path stays as opt-in tidying for anyone who wants it. Two transformations remain, both forced by Org's syntax rather than chosen: state names become TODO keywords (Org keywords can't contain spaces, so the real name stays in the LINEAR-STATE-NAME drawer), and descriptions and comments convert between Markdown and Org. Documented under "Fidelity to Linear" in the README.
* fix(commands): run add-comment and delete from anywhere in a ticketCraig Jennings2026-05-262-5/+28
| | | | | | pearl-add-comment and pearl-delete-current-issue guarded position with the nearest-heading check, so from inside a comment subtree, or any heading without a LINEAR-ID, they errored "Not on a Linear issue heading". Both docstrings already claimed they work from anywhere in the subtree, so the behavior was just wrong. The edit and save commands already climb to the issue via pearl--goto-issue-heading-or-error. I switched these two to the same guard, so every ticket-scoped command resolves the enclosing issue from anywhere in its subtree, including from inside a comment. The redundant LINEAR-ID re-checks are gone since the guard guarantees it. Walk-up tests added for both.
* feat(refresh): re-tidy drawers via org-tidy when it's activeCraig Jennings2026-05-261-0/+24
| | | | | | After a refresh, merge, or comment add, pearl folds property drawers so the page stays scannable. When org-tidy-mode is on, org-tidy owns drawer display with its own compact inline symbol, so a plain native fold doesn't match. Worse, org-tidy's overlays go stale when pearl rewrites the buffer, leaving the new drawers untidied. I added pearl--hide-or-tidy-drawers, which re-tidies via org-tidy-buffer when org-tidy-mode is active and falls back to the native fold otherwise. I routed the three drawer-hide paths (full render, single-issue refresh, comment add) through it. org-tidy stays an optional soft dependency, guarded by bound-and-true-p and fboundp, so a setup without it behaves exactly as before.
* feat(comments): fold the drawer and update the heading count on addCraig Jennings2026-05-262-4/+13
| | | | Adding a comment left three rough edges. Its property drawer rendered expanded while every other drawer in the page was folded, so I fold the new subtree's drawers right after inserting it. The Comments heading's count went stale on add: a freshly created subtree now shows 💬 Comments 1/1, and an existing 💬 Comments N/M bumps to N+1/M+1. And the fallback that creates the Comments subtree when an issue had none was still inserting a glyph-less "Comments". It now leads with the 💬 like the rest.
* feat(refresh): prompt instead of silently deferring a dirty refreshCraig Jennings2026-05-262-4/+65
| | | | Running pearl-list-issues on a buffer with unsaved edits used to print a passive "refresh deferred" message and do nothing visible, so it read as a no-op. Now it prompts: (d)iscard and rebuild, (m)erge by LINEAR-ID keeping the edits, or (q) cancel. Branch C of pearl--update-org-from-issues dispatches on the choice: discard rebuilds in place, merge hands off to the same by-id merge that pearl-refresh-current-view uses, and cancel leaves the buffer untouched.
* fix(refresh): merge keeps any locally-edited ticketCraig Jennings2026-05-261-0/+17
| | | | The merge refresh protected a subtree from being overwritten only when its description body had changed, since pearl--subtree-dirty-p is description-only. A local edit to the title, state, priority, assignee, labels, or a comment looked untouched and got re-rendered away. I added pearl--subtree-has-local-edits-p, which wraps the comprehensive per-field detector the save path already uses, and pointed both merge protection sites at it. A merge now keeps any ticket you've touched in any field. The same gain flows to pearl-refresh-current-view.
* fix(comments): don't recolor the leading heading starsCraig Jennings2026-05-261-7/+17
| | | | | | The comment overlay started at the line beginning, so it sat on top of the leading stars. With a package like org-superstar that dims those stars (and composes a bullet from them) via a face, our overlay's face overrode it and the dimmed stars reappeared in our color, a stray "***" before the bullet. The darker, body-spanning read-only overlay made it obvious, but the overlay had covered the stars since comment highlighting first landed. I start the overlay just past the leading stars now (the point right after the `^\*+ ` match), so the stars and the bullet keep org's own fontification and only the heading text and body get our face. The highlight tests check the face on the heading text and assert the stars are left uncovered.
* feat(comments): dim read-only comments so they read as disabledCraig Jennings2026-05-261-0/+24
| | | | A comment you can't edit only had a grey heading, and the grey (`shadow`) was light enough to blend with the body text of your own comments. Now the read-only overlay covers the whole comment subtree, heading and body, so the comment recedes as a unit. And `pearl-readonly-comment` uses a darker background-aware grey (gray40 on a dark theme, gray60 on light, `shadow` as the fallback). Editable comments keep the heading-only overlay so your own comment text stays normal-colored, with the green heading as the "this one's yours" cue.
* refactor: remove the dead state-to-todo mapping subsystemCraig Jennings2026-05-2614-99/+30
| | | | | | `pearl-state-to-todo-mapping` and its derived regex stopped doing real work once keyword rendering moved to slugifying the Linear state name (`pearl--state-name-to-keyword`) and the keyword-to-state direction moved to resolving through the team's workflow states. The defcustom fed only `pearl--get-todo-states-pattern`, which had no caller left, and the two cache vars existed only to serve that dead function. State extraction reads `org-get-todo-state` off the buffer's `#+TODO` line, so nothing live touched the mapping. I removed the defcustom, both cache vars, and the dead function, and dropped the three pattern tests that exercised it. Removing the defcustom orphaned the mapping bindings that ~12 test setup macros still carried. They were passing through slugification rather than the map, so I stripped them too and kept the `org-todo-keywords` bindings the temp buffers actually need. Also dropped the stale README config row.
* feat(comments): lead the Comments heading with the 💬 glyphCraig Jennings2026-05-262-10/+10
| | | | | | The Comments heading rendered the count marker as `Comments 💬 5/18`, with the glyph wedged between the word and the count. I moved the 💬 to the front of the heading, rendering `💬 Comments 5/18`, so it reads as an icon for the section rather than part of the number. `pearl--comment-count-marker` now returns just ` 5/18`, and `pearl--format-comments` leads with the glyph. The append locator that finds an issue's Comments subtree now tolerates the leading 💬, and still matches the old `Comments 💬 N/N` layout so a buffer rendered before this change keeps working on the next add-comment.
* fix(comments): apply comment highlighting on the bulk render pathCraig Jennings2026-05-261-0/+38
| | | | | | Comment headings only got their green (editable) / grey (read-only) coloring after `pearl-refresh-current-issue`, an add-comment, or a view merge (the three paths that call `pearl-highlight-comments`). A plain `pearl-list-issues` rendered through `pearl--update-org-from-issues`, which never called it, so a fresh fetch showed no coloring until I refreshed. I added the highlight call to branches A and B of `pearl--update-org-from-issues`, right after the content lands in the buffer. Branch C defers a dirty buffer without writing, so it stays uncolored. The regression test renders the comment fixture and asserts both headings carry the editability overlay (own to editable, other's to read-only) straight from the bulk render.
* feat(coverage): print a terminal coverage summary from the SimpleCov reportCraig Jennings2026-05-262-1/+165
| | | | | | `make coverage` only reported the JSON's file size, so reading the real number meant opening the report by hand. I added a self-contained `scripts/coverage-summary.el` that parses the SimpleCov JSON and prints per-file covered/total lines with a percent, a line-weighted project figure, and a source-weighted figure. A tracked source missing from the report counts as 0% rather than dropping out silently, so a source that never got instrumented still shows up. A new `coverage-summary` target runs it against the last report, and `make coverage` chains it onto the tail of a local run (CI emits coveralls.json, not simplecov.json, so it's skipped there). Covered by `tests/test-pearl-coverage-summary.el`: parser hit/executable/merge-key semantics, the per-file records, and the error paths for a missing or malformed report.
* feat: resolve a cycled TODO keyword to its Linear state at saveCraig Jennings2026-05-263-21/+104
| | | | | | Cycling a ticket's TODO keyword (C-c C-t) now counts as a state edit. The dirty scan gains a keyword-cycle arm: state is dirty when the explicit id moved off its baseline (the picker arm, as before) or when the keyword diverged from the synced state name's slug. At save, the picker arm pushes the explicit id; the keyword-cycle arm resolves the keyword to a team state id by slug match, first by position, and pushes that. A keyword no team state matches is reported skipped rather than guessed. set-heading-state now slugifies the keyword instead of the static-map lookup, so the picker writes the same keyword the renderer derives. Without that, a picker edit to a non-standard state would read as permanently cycled. That retires pearl--map-linear-state-to-org, whose only caller this was, and its tests.
* feat(render): rebuild the derived #+TODO header on merge refreshCraig Jennings2026-05-252-5/+38
| | | | | | A same-source refresh updates issue subtrees in place and keeps retained dirty subtrees the merge skips, so the #+TODO line has to stay honest across it -- a refresh can surface a new state, and a kept heading's keyword must still be declared. pearl--update-derived-todo-header scans the final displayed buffer (every LINEAR-ID heading's TODO keyword + :LINEAR-STATE-TYPE: drawer), unions that with the fetched teams' states, and rewrites the line via pearl--derive-todo-line. Scanning the final buffer rather than the fetched issue list is what covers the retained subtrees. A legacy heading with no state-type drawer is classified by org-done-keywords. pearl--merge-query-result calls it after the merge, beside the source-header update. The merge and integration test setups stub pearl--team-states so the gather stays network-free.
* feat(render): derive the #+TODO line and keywords from team workflow statesCraig Jennings2026-05-252-0/+42
| | | | | | | | The renderer wrote a fixed #+TODO line and mapped state names through a static defcustom, so a workspace's real states (Dev Review, PM Acceptance, ...) never showed up. Now the displayed issues' teams drive both. pearl--gather-header-states collects each team's full state set (cached via pearl--team-states, teams in first-seen order, states by position) plus every displayed issue's own state, so a failed team fetch still leaves its issues' states declared. pearl--build-org-content takes that list and derives the #+TODO via pearl--derive-todo-line. pearl--format-issue-as-org-entry renders each keyword with pearl--state-name-to-keyword instead of the static map -- slugify reproduces the old defaults, so standard states are unchanged -- and writes a :LINEAR-STATE-TYPE: drawer so a later merge scan can classify a retained heading. The team-states query gains type and position. The integration test stubs pearl--team-states so the render stays network-free. The merge-refresh header rebuild is the next commit. Until then a same-source refresh keeps the previous header.
* feat(render): add the pure helpers for workspace-derived TODO keywordsCraig Jennings2026-05-251-0/+87
| | | | | | Two pure functions behind the coming workspace-faithful #+TODO line. pearl--state-name-to-keyword slugifies a Linear state name to an Org keyword: upcased, non-alphanumeric runs collapsed to a single hyphen, edges trimmed, Unicode letters kept (so "Ångström" survives), and an all-symbol name falls back to TODO. pearl--derive-todo-line turns an ordered state list into the "ACTIVE... | DONE..." string: completed/canceled/duplicate types go after the bar, the rest before, order preserved within each side, de-duplicated by slug, with the hardcoded default returned for an empty list. There's no caller yet -- the render integration and the gather pipeline land next. This is the derivation half of the keyword spec. The sync-back half it described is superseded by save-model-v2 (state reconciles at save) and the separate keyword-cycle task.
* fix(save): extend the killed-buffer guard to the text-field saversCraig Jennings2026-05-251-0/+26
| | | | The atomic savers got this guard last commit, and the free-text savers share the same async exposure. pearl--save-field-push advances the provenance hash and runs the :after-push thunk through the marker after the push returns, and the description saver's outcome callback resyncs the Org hash the same way -- both signal if the buffer was killed in between. Both now check pearl--marker-live-p and skip the buffer touch while still reporting the remote outcome. I moved pearl--marker-live-p above the first caller so it's defined before all three use sites (no forward reference).
* fix(save): guard atomic-saver commit callbacks against a killed bufferCraig Jennings2026-05-251-0/+31
| | | | | | The per-field atomic savers advance the baseline and rewrite the display through the marker (org-entry-put marker / org-with-point-at marker) inside the async push callback. If the buffer is killed before the push lands, those touch a dead marker and signal. The deleted pearl--push-issue-field carried this guard, but it went away with the immediate-push setters and the atomic engine never picked it up. pearl--run-atomic-field-save now skips the commit when the marker's buffer is dead (a new pearl--marker-live-p, which also passes a nil marker for synthetic callers) and still reports the remote outcome -- the push already landed, so only the local buffer update is moot. All three commit sites are guarded: the clean push, the converged-remote case, and the use-remote conflict resolution.
* refactor(save): remove the immediate-state-push org-sync machineryCraig Jennings2026-05-254-221/+38
| | | | | | | | The opt-in org-sync mode pushed Linear state immediately — on every TODO-keyword cycle (the org-after-todo-state-change hook) and on every buffer save (the after-save hook scanning the whole file). That's the last immediate-push path, and it contradicts the one-write-path model the rest of v2 builds, so I removed it: pearl-enable-org-sync / -disable-org-sync, pearl-org-hook-function, pearl-sync-org-to-linear, pearl-sync-current-heading-to-linear, and the now-orphaned pearl--process-heading-at-point, pearl--map-org-state-to-linear, and pearl--update-issue-state-async. This finishes the "make org-sync private" task — there was nothing left to make private. Cycling a TODO keyword now marks state dirty (reconciled at the next save) instead of pushing. Saving the buffer pushes nothing. Re-introducing an auto-push on save is the separate "Automatic sync on save" task, which would call the save engine rather than this whole-file scanner. I kept pearl--extract-org-heading-properties and pearl--get-todo-states-pattern. They're general heading/keyword readers, not org-sync-specific, and the keyword-cycle save (the keyword-derivation follow-up) needs exactly them. The obsolete sync tests are replaced by two that assert the contract: the sync commands are gone, and a keyword cycle pushes nothing.
* refactor(save): retire the immediate-push field setters for buffer-editorsCraig Jennings2026-05-258-179/+79
| | | | | | | | The structured fields now reconcile at save (the previous commits), so the immediate-push setters are the duplicate path that made the package feel like it had two save models. I retired them. pearl-set-priority is deleted outright — priority is edited org-natively through the heading cookie. pearl-set-state, pearl-set-assignee, and pearl-set-labels become pearl-edit-state / -assignee / -labels: the same completing-read picker, but it writes the buffer representation (keyword/name/id, the assignee drawer, the labels drawer plus the live LINEAR-LABEL-IDS set) and marks the field dirty instead of pushing. The change goes out at the next pearl-save-issue / pearl-save-all. pearl-compose-current-description is renamed pearl-edit-description (its compose-and-push behavior is unchanged — v2 doesn't touch the compose buffers). The transient, the C-; L e keymap, the affordance preamble, and the README follow the new names, and the priority slots are dropped (no command — it's the org cookie). Deleting the setters left pearl--push-issue-field and pearl--priority-choices with no callers, so both are gone, along with the killed-buffer test that only exercised the removed helper. I filed a follow-up: the atomic savers' async commit callbacks need the same killed-buffer guard that helper carried. The setter command tests are rewritten to assert the buffer-write-and-no-push contract. The reconcile-and-push path they used to cover is exercised in test-pearl-save.
* feat(save): reconcile structured fields in save-issue and save-allCraig Jennings2026-05-251-0/+37
| | | | | | The four structured savers now run from the save engine. pearl--save-field-thunks queues a priority / state / assignee / labels saver for each dirty structured field alongside title, description, and comments, so save-issue and save-all reconcile a changed cookie, keyword-picker id, assignee, or label set at save time like any other edit. save-all's confirm prompt and the dirty-scan gate both count the structured fields, and a new pearl--issue-has-dirty-fields-p replaces the title/description/comment check that was duplicated in save-issue and the file-wide scan. This is the commit that turns the engine on. The old immediate-push setters still exist until the next commit retires them, so for this one commit both paths are live — the setter cutover and the org-sync removal follow.
* feat(save): add the priority, state, and labels saversCraig Jennings2026-05-252-5/+69
| | | | | | | | | | These are three more savers on the atomic engine, each the same spec shape the assignee saver proved, with its own field handling. Priority reads the cookie's number, diffs it against LINEAR-PRIORITY, and pushes priority. State is the picker arm — the explicit LINEAR-STATE-ID against LINEAR-STATE-ID-SYNCED, pushing stateId, with use-remote rewriting the keyword and name from the fetched node (the keyword-cycle arm stays gated on the derivation task). Labels canonicalize the id set to a sorted space-joined string so the diff is order-insensitive, and push labelIds. Canonicalizing labels to a string also keeps the missing-baseline check honest: an issue with no labels has a present-but-empty baseline, which a nil-vs-empty-list comparison would misread as a legacy file. A shared set of pearl--node-* helpers pulls each field out of the raw issue node. I also fixed pearl--get-linear-priority-name, which mapped None to "Medium" — it now reads "None", so the conflict prompt names the priority honestly. It's still not wired into save-issue — the next commit does that.
* feat(save): add the atomic field-save engine and the assignee saverCraig Jennings2026-05-251-0/+109
| | | | | | | | pearl--run-atomic-field-save is the structured-field sibling of the text engine pearl--run-field-save. It runs the atomic three-way over a spec's live and baseline values. A missing baseline skips with missing-property (a legacy file — refresh first). Live equal to baseline is unchanged. Otherwise it fetches the remote and compares: remote already equal to live converges and advances the baseline, remote unmoved from baseline is a clean push, and a genuine divergence runs the atomic conflict gate. It reuses the existing save-outcome contract, so structured fields report the same status/reason shape as title and description. pearl--save-assignee-field is the first saver on it — the cleanest relation field. Its live id is LINEAR-ASSIGNEE-ID diffed against LINEAR-ASSIGNEE-ID-SYNCED, the remote comes from the issue node, the push sends assigneeId, and use-remote rewrites the display name from the fetched node. Priority, state, and labels follow the same template in the next commit. It's not wired into save-issue yet — that's the next commit. I tested it in isolation: the engine's three-way and conflict branches over a synthetic spec, plus the assignee saver end to end with a stubbed fetch and update.
* feat(save): add an atomic conflict gate for enum and relation fieldsCraig Jennings2026-05-251-0/+56
| | | | | | | | The text conflict gate (pearl--resolve-conflict) is built for mergeable text — it can open an smerge buffer, stash to the kill ring, and advance a SHA hash. None of that fits a state, priority, assignee, or label conflict, where the value is a single id or scalar that can't be combined. pearl--resolve-atomic-conflict is the sibling for those fields: it prompts use-local / use-remote / cancel (no merge option), and delegates the actual push and the baseline advance to the caller's two callbacks, so it stays free of any field-specific mechanics. It maps to the same outcome contract as the text gate — cancel to conflict/cancelled, use-local to pushed or failed/push-failed, use-remote to resolved-remote. It's not wired into a saver yet — the per-field savers in the next commit call it.
* feat(save): detect dirty structured fields against synced baselinesCraig Jennings2026-05-253-10/+72
| | | | | | The save scan now flags priority, state, assignee, and labels as dirty when their live value differs from the synced baseline, so structured fields ride the same reconcile-at-save path as title and description. Each check is id/scalar-only and runs with no network: the priority cookie's number against LINEAR-PRIORITY, the explicit state id against LINEAR-STATE-ID-SYNCED (the picker arm — keyword cycling lands later), the assignee id against its baseline, and the label id set against its baseline (order-insensitive, since Linear label order isn't meaningful). A missing baseline on a legacy file reads as not-dirty; the saver's missing-property guard covers that edge. This also makes the priority cookie faithful. The renderer mapped both None and Medium to [#C], so a None issue couldn't be told apart from a Medium one and would have read dirty the moment its baseline said 0. None now renders with no cookie, 1:1 with the four cookie levels — matching what the priority setter already did.
* build: resolve test-file to the exact name before globbingCraig Jennings2026-05-251-1/+9
| | | | make test-file FILE=test-pearl-save grabbed test-pearl-saved.el — the glob *FILE*.el matched both and head -1 picked one arbitrarily. It now prefers an exact FILE or FILE.el match, and errors with the candidate list when a bare substring matches more than one file, instead of silently running the wrong one.
* feat(save): write synced baselines for structured fieldsCraig Jennings2026-05-251-0/+25
| | | | | | Save-model v2 reconciles a field by diffing its live value against a baseline last synced from Linear, but the structured fields (priority, state, assignee, labels) only stored their current value — there was nothing to diff against. The renderer now writes a synced baseline beside each: LINEAR-PRIORITY (the numeric priority), LINEAR-STATE-ID-SYNCED, LINEAR-ASSIGNEE-ID-SYNCED, and LINEAR-LABEL-IDS / LINEAR-LABEL-IDS-SYNCED (the live and baseline id sets, with LINEAR-LABELS staying display-only names). At fetch, live equals baseline, so nothing reads dirty on a fresh issue. The merge refresh re-renders changed subtrees through the same renderer, so baselines advance there too, and a retained dirty subtree is left untouched.
* refactor: rename issue-body tests to match entry-body-at-pointCraig Jennings2026-05-251-5/+5
|
* feat: render inline code spans verbatim in the Org bufferCraig Jennings2026-05-251-4/+16
| | | | | | | | A fetched description with markdown inside backticks — `**bold**`, `[text](url)`, `_italic_` — got its span contents converted along with the surrounding prose, so the Org buffer showed `*bold*` and `[[url][text]]` inside what should read as literal code. Markdown treats a code span as opaque; the converter didn't. Both converters now tokenize code spans first and apply link/bold/italic conversion only to the text between them, keeping span contents literal. The md->org and org->md directions each split into an emphasis-only helper (pearl--md-emphasis-to-org, pearl--org-emphasis-to-md) called on the gaps, with the span passed through untouched. Keeping the split symmetric is what preserves the round-trip identity a fetch + no-edit push depends on. A new test asserts the verbatim rendering for bold, link, and italic inside a span, plus emphasis still converting outside one and between two spans.
* refactor: rename pearl--issue-body-at-point to pearl--entry-body-at-pointCraig Jennings2026-05-255-12/+12
| | | | The helper returns the body of the Org entry at point, before any child heading. It started out reading issue descriptions, but comment editing and deletion reuse it for comment bodies too, so "issue" in the name was misleading. I renamed it across its callers and the sync/refresh/save/comment tests, and reworded the docstring to say "entry".
* fix: guard issue async callbacks against a killed bufferCraig Jennings2026-05-251-0/+25
| | | | | | pearl--push-issue-field (the field setters) and pearl-refresh-current-issue ran goto-char on the captured marker in their success callbacks with no liveness check. If the buffer was killed before the async response returned, the dead marker signaled "Marker does not point anywhere" from the callback. I wrapped the buffer work in a buffer-live-p check; the field setters still report the remote success, and refresh reports that the buffer was closed. This closes the last gap in the command/context test-coverage task. The other seven items it named were already covered by the quick-bug batch's tests; this adds the killed-buffer regression for the issue paths (delete-current-comment already had its own).
* fix: seven quick bugs from the 2026-05-25 code reviewCraig Jennings2026-05-251-0/+155
| | | | | | | | | | | | | | | | | | A batch of small, independent fixes a code-review pass turned up, each its own :bug:quick:solo: todo. They share no code path beyond pearl.el, so the body lists them. Single-issue refresh false-stash: pearl-refresh-current-issue decided whether to stash "local edits" by hashing org->md against LINEAR-DESC-SHA256, the lossy markdown round-trip. A clean issue whose description has lossy markdown (a # heading, single-asterisk italics) looked dirty and got stashed before every refresh. It now uses pearl--subtree-dirty-p, the Org-hash-first check the merge refresh already uses. Comment outcome parent id: pearl--save-comment-field read :issue-id from the comment heading, which carries LINEAR-COMMENT-ID, not LINEAR-ID, so comment outcomes got :issue-id nil against the contract. A new pearl--issue-id-at-point climbs to the enclosing issue. save-all viewer-unavailable count: when the viewer lookup failed, save-all passed a nil viewer id and the prompt counted every dirty comment as read-only, disagreeing with the actual skipped/viewer-unavailable outcome. The counts and prompt now carry an explicit viewer-unavailable tally. Request-counter leak: pearl--graphql-request-async incremented pearl--active-requests before pearl--headers, which signals when the key is unset, so neither callback ran to decrement it. I build the headers before the increment. Issue commands from comment subtrees: save-issue, refresh, compose-description, open-issue, and the field setters read LINEAR-ID from the nearest heading, so running them from inside a comment hit the comment heading and rejected with "Not on a Linear issue heading". A new pearl--goto-issue-heading-or-error climbs to the issue heading; the commands use it. Project selector on no projects: pearl-select-project ran (string= selected "None") even when a team had no projects and selected was nil. I guard the nil case. (The reported wrong-type crash doesn't reproduce here, since string= treats nil as the symbol "nil", but the guard makes the optional-no-projects path explicit.) Query filter validation: pearl-run-saved-query, pearl-list-issues-filtered, and pearl-list-issues compiled their authoring plist without calling pearl--validate-issue-filter, so an unknown key in a saved query was silently ignored. They now validate at the command boundary, so typos surface as a clear user-error.