| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
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-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.
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
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".
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
The hot key l is hardwired to my-open-issues. This spec lets l open a user-chosen default instead, defaulting to my-open-issues so nothing changes until you set one.
Four decisions settled before code. The value is nil or a local-view name (default to a Linear view by saving it locally first). The default is per-account when accounts are configured, because local views are account-stamped and a global default would refuse to run cross-account. A new pearl-open-default-view takes l, and pearl-list-issues stays literal at f o. The setter persists through customize-save-variable, and it's the v D slot the keybinding work reserved.
Ready after a Codex round: the transient set-default-view key is . since D is taken by save-locally, and a test-surface task was added.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
I documented the new multi-state capability and brought the views spec and README back in sync with it. The README's filter section notes the State prompt is multi-select and shows the (:state ("Todo" "In Review")) form.
The local/Linear views spec's reverse-compile dimension table, singular-vs-plural rule, and refusal list now list state.name.in as a plural-capable exception alongside :labels and :state-type, and the README copy-down paragraph no longer claims a multi-value filter on any single dimension refuses. The views vNext note records that the named-states case shipped here.
Phase 3 of docs/multi-state-filter-spec.org. Docs only.
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
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.
|
| |
|
|
|
|
| |
I wrote a spec for letting a filter match several workflow states at once. The builder picks one state today, so "Todo or In Review" can't be built. The design makes :state polymorphic, a name or a list of names, mirroring how :state-type already works, so (:state ("Todo" "In Review")) compiles to state.name.in.
It covers the four filter-side readers (compiler, builder, validator, reverse-compile), reconciles the views spec's representability boundary, and notes the issue's own state field is a separate, unaffected use. Codex review incorporated. Status Ready.
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
I rewrote the user-facing docs around filter / local view / Linear view, with no "query" left in the README or package summary. The keymap quick-reference shows the new bindings: run local at f l, run Linear at f v, publish at f u, save-locally at f d, and create / edit / delete local views at c v / e v / k v.
The Sources section now states the source-of-truth rule: a published local view runs your local filter, not the Linear mirror, and pearl-run-linear-view runs the server-side version. The Publishing section reads "publish" instead of "sync", and a new "Saving a Linear view locally" section documents copy-down, including the representability boundary and the refuse-don't-guess behavior. A "Creating and editing local views" table covers create, edit, and save-locally, and the account guard on publish and delete is noted.
Phase 7 of the views spec.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
I put the view operations under the verb a user would reach for. Create, edit, and delete local views are c v, e v, and k v. The fetch group gains run-local-view at f l and keeps run-linear-view at f v, with publish at f u and save-locally reserved at f d. Since f p already runs issues by project, publish and save-locally take u and d, which read as up and down and mirror the copy-up and copy-down lifecycle.
The transient now has a Views column grouping create, edit, run-local, run-linear, publish, and delete, separate from the source-fetching commands. I updated the spec's keymap to the implemented letters so the two stay in sync.
Phase 3 of the views spec. Keymap tests assert the new bindings. Suite, compile, and lint are green.
|
| |
|
|
|
|
|
|
|
|
| |
I added pearl-create-local-view and pearl-edit-local-view, plus the writer and guards the spec calls for. pearl--save-local-view replaces the old rebuild-from-scratch writer. It copies an existing entry and updates only the filter, sort, and order, so a published view keeps its :linear-view-* tracking link and a copied-down view keeps its provenance. A new entry gets the active account stamped under pearl-accounts.
pearl--require-local-view-account is the one guard every read or mutate command shares, and pearl-run-local-view now calls it instead of an inline check. pearl--resolve-local-view-name carries the Replace/Rename/Cancel collision policy for create, edit-rename, and copy-down. The ad-hoc builder's save offer routes through the preserving writer and reads "save this as a local view".
Edit re-runs the builder when you choose to rebuild and otherwise keeps the stored filter. Full per-dimension pre-seeding of the builder is deferred, since it needs the id-to-name reverse resolution that copy-down's reverse-compile introduces. I filed it as a follow-up.
Phase 2 of the views spec. Tests cover metadata preservation, the account guard, the collision policy, and the metadata-preserving rename. Suite, compile, and lint are green.
|
| |
|
|
|
|
|
|
| |
I renamed Pearl's "saved query" surface to the local/Linear view vocabulary, with no obsolete aliases, since Pearl has no users to protect. The defcustom pearl-saved-queries is now pearl-local-views. The commands pearl-run-saved-query, pearl-delete-saved-query, pearl-sync-saved-query-to-linear, pearl-run-view, and pearl-publish-current-source become pearl-run-local-view, pearl-delete-local-view, pearl-publish-local-view, pearl-run-linear-view, and pearl-publish-current-view.
User-facing prompts, messages, and docstrings drop "saved query" for "local view", and the publish command reads "publish" instead of "sync". Internal GraphQL and helper names keep their "query" terms, which a user never sees. A naming-regression test asserts the new symbols exist, the old ones are gone rather than aliased, and no public command exposes "query".
Phase 1 of docs/local-and-linear-views-spec.org. No behavior change. Suite, compile, and lint are green.
|
| |
|
|
|
|
|
|
| |
The spec is implementation-ready. It pins the design the earlier drafts left open, across the parts that turned out to need real contracts rather than prose.
Copy-down's reverse-compile is the load-bearing piece. A live probe verified that CustomView.filterData is readable but stored as Linear's and/or view-filter tree, not the IssueFilter shape Pearl's compiler emits, so the reverse-compile is a normalize-then-match: flatten the tree, unwrap single-branch or/and, read one-element in as a scalar, then match each conjunct to a Pearl authoring key. The per-dimension contract is explicit, including the cases where the compiled filter loses information: :state and :state-type stay distinct, :priority canonicalizes to the integer, :open t maps 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 the model refuses with a structured reason instead of producing a lossy local view.
The rest of the surface is settled too. A tracked local view runs the local filter, the source of truth, not its Linear mirror. Edit preserves all non-edited metadata through a dedicated writer. One account guard covers every read and mutate, with delete and publish refusing before any Linear call on a mismatch. Local and cross-store name collisions have a defined policy. Vocabulary is direct-renamed to filter / local view / Linear view, with intent-first publish and save-locally commands and no aliases. Agreed decisions, acceptance criteria, a phased plan, and the dispositions are recorded inline.
|
| |
|
|
|
|
|
|
| |
I expanded the spec to make copying a view between the two stores a first-class operation in both directions, and deliberately asymmetric. Copy up (local view to Linear view) is publish: it creates a tracked Linear view, and re-copying updates it in place. Copy down (Linear view to local view) is a fork: it duplicates the Linear view's filter into a new, independent local view you can edit and rename, with no sync link back. The principle behind the asymmetry is that the local view is the editable source of truth.
Copy down is the one direction that needs real engine work. Because the result has to be editable, it can't store opaque filter JSON. It has to reverse-compile Linear's IssueFilter back into Pearl's authoring plist. The spec carries the dimension table the reverse-compile inverts, and draws the representability boundary: when a Linear view's filter uses OR logic, an unmodeled dimension, or an operator Pearl doesn't emit, copy down refuses with a clear message rather than dropping conditions silently. A local view that looks right but matches a different issue set, diverging on every refresh, is the worse failure.
I also added the two decisions the change forces (refuse-don't-guess on unrepresentable filters, fork-not-track on copy down), the copy-up naming question, keymap slots, acceptance criteria for both round-trips, and two implementation phases. The spec is still a Draft, now with eight open questions.
|
| |
|
|
|
|
|
|
| |
I drafted a spec that renames "saved query" to one noun with a location qualifier: a local view (private, on disk) and a Linear view (published, shared, the CustomView Linear users already know). The lifecycle is git-shaped: a local view is a local branch, a Linear view a remote branch, publish is push, and the stored view-id is the tracking link.
The model already exists in the code. What's missing is a coherent name and command shape. Today the saved-query operations scatter across the verb-first keymap: create is hidden inside the filter builder and filed under fetch, delete has a home, edit doesn't exist. The spec covers the rename map (obsolete aliases so no init.el breaks), new create and edit commands, the keymap/transient/picker reshape, and the config migration. One piece isn't a rename: pulling a Linear view back into a local view needs a reverse-compile of IssueFilter, deferred to vNext.
Status is Draft with six open questions. No code changed.
|
| |
|
|
| |
GitHub Actions only run on github.com, and cjennings/pearl isn't there, so the workflow had nowhere to run and the README badge 404'd. I removed both. The workflow is preserved in history at 9311cb8 if a mirror repo gets set up later. The compose-window and refresh-view README docs stay; they document real features and don't depend on the repo.
|
| |
|
|
| |
Two shipped behaviors users can see weren't in the README. pearl-compose-window-side and pearl-compose-window-size control where the description/comment editor pops and how big it is. Both now appear in the configuration table. And the refresh section now explains that pearl-refresh-current-view merges by issue id and keeps point and your fold state, re-folding only the issues that changed, so editing then refreshing doesn't collapse what you were working in.
|
| |
|
|
| |
There was no CI: the suite, byte-compile, and lint only ran when I ran them locally, so a regression could land on main unnoticed until the next manual run. This adds a workflow that runs make compile, make lint, and make test on every push to main and every PR, across Emacs 27.2 through 30.1 (27.1 is the package floor). An advisory snapshot job surfaces upcoming-Emacs breakage without blocking a merge. The README carries a status badge.
|
| |
|
|
| |
The setup check gated on pearl-api-key, which is normally unset once pearl-accounts is configured, so under multi-account it wrongly reported "API key is not set" and skipped the connection test even when the active account resolved a key fine. Under accounts mode it now resolves the active account's context, names it, and runs the test. An unusable setup (no default, missing key) reports the reason instead of signaling. Legacy single-account behavior is unchanged. The resolved key is never printed.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
I added a named-account layer for working more than one Linear workspace from one Emacs.
Before this, everything that identified a workspace was a single global: pearl-api-key, pearl-graphql-url, pearl-org-file-path, and the lookup caches. Nothing stopped a work command from running under personal credentials or a work fetch from landing in the personal file, and switching accounts meant re-customizing the key, team, and file by hand and clearing the cache.
pearl-accounts maps a name to a per-workspace plist (credential source, org file, default team, optional endpoint), and pearl-switch-account makes one active. Account state flows through an explicit context rather than mutating globals. Every request snapshots its account at dispatch through pearl--graphql-request-async and re-establishes it around the callbacks. A switch mid-fetch can't bleed into a request already in flight: the result finishes into the account it was dispatched under. I centralized this in the one request primitive, so the leak surface is a single function instead of every call site.
Rendered files carry a #+LINEAR-ACCOUNT marker, and a buffer guard refuses a command run from one account's file while another is active, naming both, so a work edit can't push under personal credentials. An unmarked legacy file lets reads and refreshes through and acquires its marker on the first refresh. Mutations wait until then. Credentials resolve through auth-source, an env var, or an inline literal, and a resolved key is never persisted through Customize or logged. The active account shows in the mode line. A saved query can carry an :account so it refuses to run under the wrong workspace before any lookup.
With pearl-accounts unset, everything behaves exactly as before, off the legacy globals.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The previous commit (eb56295) shipped the buggy pre-fix version of pearl--merge-issues-into-buffer along with a too-weak test. The intended fix and the strengthened test were sitting uncommitted in the working tree when I ran git commit, because I forgot to re-stage pearl.el and tests/test-pearl-merge.el after addressing the critical correctness finding from pre-commit review. The test that landed asserts each touched marker leads to some LINEAR-ID heading, which passes against the buggy code (the markers point at the wrong heading, but it's still a valid heading).
This commit is the fix and the real test, which together honor what eb56295's message described.
Code change: pearl--merge-issues-into-buffer captures a type-nil copy of the alist marker BEFORE calling pearl--replace-issue-subtree-at-point, instead of pushing the original type-t marker after the replace. The alist marker is insertion-type t (it needs to advance past replaces of EARLIER subtrees so it stays anchored to its own heading), so reusing it after the current subtree's delete-then-insert captures the post-advance position, which is the NEXT subtree's heading. A type-nil copy made before the replace stays anchored at the deletion start and lands on the new heading after the re-insert.
Test change: the returns-touched-markers test now resolves each marker to its heading's LINEAR-ID and asserts the set matches {a, b, c} exactly (for 2 updated + 1 added), instead of just checking each marker reaches some heading. With the buggy code the IDs would resolve to {b, c, c} (a's marker advanced to b's heading, b's marker advanced to c's added heading). The strengthened assertion fails on the bug.
All 674 ert tests pass. make compile and make lint are clean.
|
| |
|
|
|
|
|
|
|
|
|
|
| |
A merge refresh (the "merge keeping edits" choice on a dirty buffer, or pearl-refresh-current-view on a clean one) used to call pearl--restore-page-visibility at the tail, re-folding the whole buffer to its #+STARTUP overview. That collapsed every subtree the user had expanded, including the comment they were just editing, and disturbed point. The edit-then-merge flow felt jarring.
The fix: pearl--merge-issues-into-buffer now returns a marker for every subtree it re-rendered or appended, alongside the existing counts (:touched-markers (M1 M2 ...)). The merge call sites fold just those subtrees via a new pearl--fold-touched-subtrees helper. Kept (locally-edited) and untouched subtrees stay exactly as the user had them, and point is preserved because the localized fold doesn't touch the rest of the page.
The full-rebuild paths (Branch A no-buffer, Branch B clean-buffer replace, Branch C-discard) still call pearl--restore-page-visibility. They re-render the whole buffer so the global re-fold is the right behavior.
Pre-commit review caught a critical correctness bug in the first draft: the alist marker from pearl--issue-subtree-markers is insertion-type t (it needs to advance past replaces of EARLIER subtrees so it stays anchored to its own heading), and reusing it after pearl--replace-issue-subtree-at-point captured the post-advance position, which is the NEXT subtree's heading, so the fold collapsed the wrong subtree. The fix copies the marker as type-nil BEFORE the replace, so it stays anchored at the deletion start and lands on the new heading after the re-insert. I strengthened the marker test to verify the IDs at the touched markers match the expected set ({a, b, c} for 2 updated + 1 added), not just that they're some valid headings. The original test would have passed against the bug.
Five new tests in test-pearl-merge.el cover: merge returns markers with the correct IDs at their headings, skipped (locally-edited) issues don't add to touched-markers, fold-subtree-at-marker hides body content while leaving the heading visible, fold-touched-subtrees is a no-op when pearl-fold-after-update is nil, and fold-subtree-at-marker tolerates a nil marker silently. All 674 ert tests pass. make compile and make lint are clean.
|
| |
|
|
|
|
|
|
|
|
| |
Pearl was rendering the Comments subtree heading as `*** 💬 Comments [N/M]`. Craig flagged that it looks strange, and the renderer should work cleanly before it gets glyphed. I dropped the emoji prefix so the heading reads `*** Comments [N/M]`. Partly reverts 41a3396. The overlay-glyph idea (a content-aware display overlay on the leading stars) stays tracked as a separate task.
I touched pearl--format-comments (the new-subtree literal) and pearl--append-comment-to-issue (both the new-subtree insert and a couple of stale docstring references). The append-locator regex stays tolerant of the legacy `💬 Comments` heading and of the older pre-2026-05 `Comments 💬 N/N` trailing-glyph layout. Buffers rendered in any of the three states still locate their Comments heading on append.
I added one regression test (test-pearl-append-comment-locates-legacy-emoji-prefixed-heading) that seeds a `*** 💬 Comments 1/1` heading and asserts append still finds it, bumps the count to 2/2, and doesn't add a second Comments heading.
I updated assertions in test-pearl-comments.el, test-pearl-list-comments.el, and test-integration-acceptance.el to expect the no-emoji form. I also updated docstrings on pearl-fetch-comments-in-list, pearl-list-comments-count-cap, pearl--format-comments, pearl--bump-comments-count-marker, and pearl--comment-count-marker. The README's sample buffer block now matches what the renderer emits. All 669 ert tests pass. make compile and make lint are clean.
|
| |
|
|
|
|
|
|
|
|
| |
pearl-sync-saved-query-to-linear used to rebuild the entire scope-candidates list a second time (full sort + 2N row allocations) just to rassoc the chosen scope plist back to its display string for the success message. The picker had thrown away the display string the user picked, so the caller had no choice but to re-derive it.
I changed pearl--sync-saved-query-pick-scope to return (DISPLAY . PLIST) instead of just PLIST. The caller now reads scope-label from (car scope-pair) and scope from (cdr scope-pair). The rassoc is gone, and so is the second pearl--sync-scope-candidates call.
To keep the re-sync path label-consistent with the picker (so a "Synced X (Team: Engineering, visible to the team)" message matches what the picker would have shown for the equivalent first-time sync), I extracted pearl--sync-scope-label as the single source of truth for the label format. The picker, the re-sync branch, and any future caller that needs to describe a scope all go through the helper. Personal scope always renders "[ Personal, only I see it ]" regardless of the shared flag (a Personal + shared combination is meaningless on Linear). Team scopes render "[ Team: NAME, visible to the team ]" or "[ Team: NAME, only I see it ]" per the flag.
I added five tests covering the new behavior: scope-label for personal (ignores shared) and team (both shared and private), pick-scope returns the team pair, pick-scope returns the personal pair, pick-scope returns nil on cancel. All 668 ert tests pass. make compile and make lint are clean.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
This is Phase 6 of docs/saved-query-sync-spec.org, the docs commit that lands the saved-query → Linear view sync work.
I added a new "Publishing a saved query as a Linear view" subsection under Commands → Sources. It walks through the two publish commands (sync-saved-query-to-linear and publish-current-source) with their full bindings, the scope-and-visibility prompt the user sees on first sync, the Replace/Rename/Cancel collision flow, the four :linear-view-* metadata keys + :linear-view-url that get persisted, the one-way push semantics, the v1 caveat that :sort and :order don't sync up, and the two-stage delete-saved-query prompt for synced entries.
I updated the existing Sources example block to include the new [saved → <scope>] picker labels (team-scoped, Personal, and the stale-team `?` case), with a paragraph explaining how the dispatch routes those through customView(id:) on Linear rather than re-running the local filter.
I extended the prefix-keymap quick-reference table with f S (sync), f P (publish), and k q (delete saved query) because they were bound in pearl-mode but missing from the table. The Fetching and refreshing command table gained rows for sync-saved-query, publish-current-source, and delete-saved-query.
I flipped docs/saved-query-sync-spec.org Status from Ready to Done with the four implementation-phase commit hashes (60a026b, c8b9ad1, fd94237, 0975e7d).
No code changes. 660 ert tests still green. make compile and make lint clean.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
/ [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.
|
| |
|
|
|
|
|
|
|
|
| |
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.
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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.
|