aboutsummaryrefslogtreecommitdiff
path: root/docs/local-and-linear-views-spec.org
diff options
context:
space:
mode:
Diffstat (limited to 'docs/local-and-linear-views-spec.org')
-rw-r--r--docs/local-and-linear-views-spec.org98
1 files changed, 72 insertions, 26 deletions
diff --git a/docs/local-and-linear-views-spec.org b/docs/local-and-linear-views-spec.org
index c163526..82de89e 100644
--- a/docs/local-and-linear-views-spec.org
+++ b/docs/local-and-linear-views-spec.org
@@ -8,6 +8,8 @@
This is the successor framing to =docs/saved-query-sync-spec.org= (which shipped the local→Linear sync) and =docs/issue-sources-spec.org= (which unified the fetch surface behind =pearl-pick-source=). It does not redo their work; it renames and re-homes it so the whole lifecycle speaks one vocabulary.
+*2026-06-01 addition:* bidirectional copy. Craig asked for both directions as first-class operations — *copy down* (a Linear view → a new, editable, renamable local view) and *copy up* (a local view → a Linear view). Copy-down is the piece the first draft had deferred to vNext; it is now in scope, which pulls the =IssueFilter= reverse-compile (and its representability boundary) into the main design.
+
* Problem
Pearl already has the machinery for named, reusable filters and for pushing them up to Linear. What it lacks is a coherent *name* and a coherent *command shape*, and the gap shows up the moment a user goes looking for an operation by the noun:
@@ -39,16 +41,23 @@ One noun — *view* — with a qualifier that names where it lives. The qualifie
The lifecycle is git-shaped, which is the analogy that makes it self-explanatory:
-| Pearl | git |
-|------------------------------------+---------------------------|
-| local view | local branch |
-| Linear view | remote branch |
-| publish (local view → Linear view) | push |
-| pull (Linear view → local view) | fetch + checkout (vNext) |
-| the =:linear-view-id= link | the upstream tracking ref |
+| Pearl | git |
+|--------------------------------------------+-------------------------------------------|
+| local view | local branch |
+| Linear view | remote branch |
+| copy up (local view → Linear view) | push — creates a tracked Linear mirror |
+| copy down (Linear view → local view) | branch off — a new, independent local view |
+| the =:linear-view-id= link | the upstream tracking ref (copy-up only) |
A view can have a local copy, a Linear copy, or both. Pearl already stores exactly this: a =pearl-saved-queries= entry with no =:linear-view-id= is local-only; one *with* a =:linear-view-id= is a local view that has been published and now tracks a Linear view. The data model is already git-shaped — this spec names it.
+*Copy is the verb for moving a view between the two stores, in both directions, and the two directions are deliberately asymmetric:*
+
+- *Copy up* (local view → Linear view) is *publish* — it creates a Linear view and keeps the =:linear-view-id= link, so editing the local view and copying up again *updates the same Linear view in place* rather than spawning duplicates. This is the existing sync machinery.
+- *Copy down* (Linear view → local view) is a *fork* — it duplicates the Linear view's filter into a new, independent local view you then own, edit, and rename freely. It does *not* track the source: you copied it down precisely to diverge from it, and once you rename it, a live link would only mislead. Provenance may be recorded for reference (a =:copied-from-view-id=), but it is not a sync link.
+
+The asymmetry follows from one principle: *the local view is the editable source of truth.* Copy-up keeps a Linear mirror synced to that source; copy-down forks a Linear view into a *new* local source you take ownership of. If, after copying down and editing, you want it back on Linear, you copy up — which creates a new, separately tracked Linear view.
+
Two facts the model has to keep honest:
1. *=filter= is not retired.* It stays the conditions-building step (Linear's "Filter" button) and the on-disk authoring plist. You *filter* to assemble conditions; when you name and keep the result, it is a *local view*. An unsaved run is an *ad-hoc filter* — not yet any kind of view.
@@ -72,7 +81,7 @@ Two facts the model has to keep honest:
- No *create* command under the create group — creation is a side effect of the filter builder.
- No *edit* command at all — a local view's filter/name/sort/order can't be changed in Emacs.
-- No *pull* — a Linear view authored on Linear's side can be *run* but not saved as a local view (requires reverse-compiling =IssueFilter= back into Pearl's authoring plist; the compiler is currently one-way).
+- No *copy down* — a Linear view authored on Linear's side can be *run* but not copied into an editable local view (requires reverse-compiling =IssueFilter= back into Pearl's authoring plist; the compiler is currently one-way).
- The vocabulary: nothing says "view"; everything says "saved query".
* Proposed design
@@ -100,7 +109,10 @@ New commands:
|------------------------------+------------------------------------------------------------------------------|
| =pearl-create-local-view= | Build a filter interactively and save it as a named local view (create slot) |
| =pearl-edit-local-view= | Edit an existing local view: re-run the builder pre-seeded with its filter, and adjust name / sort / order |
-| =pearl-pull-linear-view= (vNext) | Save a Linear view (custom view / favorite) as a local view — needs the reverse-compile (see below) |
+| =pearl-copy-view-from-linear= | *Copy down.* Pick a Linear view (custom view / favorite), reverse-compile its filter, and save it as a new, independent, editable local view (prompts for a name) |
+| =pearl-copy-view-to-linear= | *Copy up.* Publish a local view as a Linear view (the existing sync machinery, framed as copy; re-copy updates the tracked view in place) |
+
+=pearl-copy-view-to-linear= is the same operation as =pearl-publish-local-view= in the rename map above — copy-up *is* publish. The spec carries both names so the copy/copy symmetry is visible; Open question 7 settles which is the user-facing primary and which is the alias.
=pearl-list-issues-filtered= stays as the *ad-hoc, save-less* filter run (rename optional; see Open question 2). =pearl-create-local-view= becomes the deliberate create path; it may share the builder internals with =pearl-list-issues-filtered= and =pearl-edit-local-view=.
@@ -112,8 +124,8 @@ Every view operation hangs off the *view* noun under the verb where a user would
- *edit* a local view — =pearl-edit-local-view=
- *run* a view (local or Linear) — =pearl-pick-source= (unified) plus direct =pearl-run-local-view= / =pearl-run-linear-view=
- *delete* a local view — =pearl-delete-local-view=
-- *publish* a local view → Linear view — =pearl-publish-local-view= (and =pearl-publish-current-view=)
-- *pull* a Linear view → local view — =pearl-pull-linear-view= (vNext)
+- *copy up* a local view → Linear view (publish) — =pearl-copy-view-to-linear= / =pearl-publish-local-view= (and =pearl-publish-current-view=)
+- *copy down* a Linear view → local view (fork) — =pearl-copy-view-from-linear=
** Keymap reshape (proposed — keys open to review)
@@ -127,9 +139,9 @@ Goal: the =view= noun reachable under each verb, with a consistent sub-key. The
| =C-; L f s= | =pearl-pick-source= (run any view) |
| =C-; L f l= | =pearl-run-local-view= |
| =C-; L f v= | =pearl-run-linear-view= |
-| =C-; L f p= | =pearl-publish-local-view= |
+| =C-; L f p= | =pearl-copy-view-to-linear= (publish) |
| =C-; L f P= | =pearl-publish-current-view= |
-| =C-; L f u= | =pearl-pull-linear-view= (vNext) |
+| =C-; L f d= | =pearl-copy-view-from-linear= (copy down) |
The old =q= (saved query) bindings stay live via the obsolete aliases during the deprecation window; the help labels switch to the new vocabulary immediately. Exact key letters are the most reviewable detail here — the principle (one noun, present under every applicable verb) is the load-bearing part.
@@ -147,14 +159,42 @@ Reword =pearl--pick-source-candidates= / =pearl--saved-query-scope-label= so the
This directly answers the "which of my views are public?" question Craig wanted to see — the label *is* the public/private indicator.
-** The pull direction (the one piece that isn't cosmetic)
+** Copy up (local view → Linear view)
+
+This is the existing publish/sync path, unchanged in behavior. =pearl-copy-view-to-linear= picks a local view, creates a =CustomView= on first copy (prompting scope + visibility), and on a re-copy updates the tracked view in place via =customViewUpdate= against the stored =:linear-view-id=. Name collisions in the target scope prompt Replace/Rename/Cancel. The only change here is vocabulary: "sync" becomes "copy up", and the command is presented as the symmetric partner of copy-down. No new engine work.
+
+** Copy down (Linear view → local view)
+
+This is the one direction that needs new engine work, and Craig wants the result *editable and renamable* — so an opaque, run-only copy is not enough. The local view it produces has to carry a real authoring filter that =pearl-edit-local-view= can re-open in the builder.
+
+*The reverse-compile.* Pearl's filter compiler is one-way today: authoring plist → =IssueFilter= JSON (=pearl--build-issue-filter=). Copy-down needs the inverse — read a Linear view's =filterData= (an =IssueFilter=) and reconstruct the authoring plist. The compiler's output is a flat AND of a fixed dimension set, each with one operator shape:
+
+| Authoring key | =IssueFilter= shape Pearl emits / inverts |
+|--------------------------------+---------------------------------------------------|
+| =:assignee :me= | =assignee.isMe = true= |
+| =:assignee= EMAIL | =assignee.email.eq= |
+| =:assignee-id= ID | =assignee.id.eq= |
+| =:state= ... | =state= (per =pearl--compile-state-filter=) |
+| =:project= ID | =project.id.eq= |
+| =:team= KEY | =team.key.eq= |
+| =:labels= (NAMES) | =labels.some.name.in= |
+| =:label-id= ID | =labels.some.id.in= |
+| =:priority= ... | =priority.eq= |
+| =:cycle= ID | =cycle.id.eq= |
+
+The reverse-compile recognizes exactly these shapes and inverts them. Round-trip tests (authoring plist → =build-issue-filter= → reverse → identical plist) are the spine of the phase.
+
+*The representability boundary — and why copy-down refuses rather than guesses.* Linear's =IssueFilter= is far richer than Pearl's authoring model. A Linear view can express:
+
+- *OR logic and nested =and=/=or=* — Pearl's authoring filter is flat AND only.
+- *Operators Pearl doesn't emit* — =neq=, =nin=, =null=, date/number comparators (=gt=, =lt= on =createdAt=, =dueDate=, =estimate=), =labels.every= / =labels.none= (Pearl only does =some=).
+- *Dimensions Pearl doesn't model* — creator, subscribers, parent, project milestone, estimate, due date, and others.
-=pearl-pull-linear-view= is the only proposed operation that needs new engine work, not a rename. Pearl's filter compiler is one-way: authoring plist → =IssueFilter= JSON (=pearl--build-issue-filter=). Pulling a Linear view into a *local* view means reading that view's =filterData= (an =IssueFilter=) back into a Pearl authoring plist — a reverse-compile that does not exist. Two honest options:
+When a Linear view's filter contains anything outside the table above, copy-down *refuses with a clear message naming the unsupported construct* ("this Linear view uses OR logic / a due-date filter / a creator filter that Pearl's local views can't represent yet — run it directly with =pearl-run-linear-view= instead"). It does *not* silently drop the unsupported conditions: a local view that looks like the Linear view but quietly matches a different set of issues is the worst possible outcome — every refresh would diverge invisibly. Refuse-don't-guess is the rule.
-1. *Full reverse-compile* — map every =IssueFilter= shape Pearl emits back to the authoring plist. Bounded but real; round-trip tests required.
-2. *Opaque local view* — store the pulled =filterData= verbatim and run it as-is, without making it editable in Pearl's builder. Cheaper, but an opaque local view can't be edited via =pearl-edit-local-view=, which dents the model's symmetry.
+This bounds the feature honestly: copy-down works for any Linear view whose filter lives inside Pearl's authoring model (the common case — a team + state + label + assignee AND-filter), and refuses, legibly, for the richer ones. The refused set shrinks over time as the authoring model grows (OR support is its own future spec).
-Recommend deferring pull to vNext and shipping the rename + create + edit + publish reshape first (Open question 3).
+*Source of the copy-down candidate.* Copy-down reuses the picker's Linear-view enumeration (custom views + favorites that resolve to views) so the source list is the same one =pearl-run-linear-view= / =pearl-pick-source= already build. The chosen view's =filterData= is fetched, reverse-compiled, and saved as a new local view under a prompted name (defaulting to the Linear view's name), with no =:linear-view-id= (it's a fork). Optional =:copied-from-view-id= provenance is recorded for reference only.
* Migration
@@ -167,17 +207,22 @@ Recommend deferring pull to vNext and shipping the rename + create + edit + publ
1. *Variable name:* =pearl-local-views= (recommended), =pearl-saved-views=, or keep =pearl-saved-queries= as the storage name and only relabel the UI? Recommend the rename with an obsolete-alias — the whole point is that the vocabulary is consistent end to end.
2. *Ad-hoc builder:* does =pearl-list-issues-filtered= keep its inline "save this filter?" prompt, or does creation move entirely to =pearl-create-local-view= (leaving =pearl-list-issues-filtered= as a pure save-less ad-hoc run)? Recommend a dedicated create command + keep the inline prompt too (two doors to the same create), unless you'd rather have exactly one create path.
-3. *Pull in v1 or vNext?* Recommend vNext — it is the only non-cosmetic piece and needs the reverse-compile. The rest (rename + create + edit + publish reshape) is shippable without it.
+3. *Copy-down representability — refuse or fall back?* When a Linear view's filter is outside Pearl's authoring model (OR logic, unmodeled dimension, comparator), the recommendation is *refuse with a clear message* and point the user at =pearl-run-linear-view=. The alternative is a fallback opaque, run-only local copy that =pearl-edit-local-view= would reject. Recommend refuse — a silently-lossy local view that diverges on every refresh is the worse failure. Confirm.
4. *Edit semantics:* re-run the builder pre-seeded with the stored filter (recommended, reuses the builder), or a field-by-field editor? Pre-seeded re-run is simpler and matches how filters are authored.
-5. *Keymap letters* for local-view run vs Linear-view run in the =f= group (the =v= collision). The table above is a proposal; your call on the letters.
+5. *Keymap letters* for local-view run vs Linear-view run in the =f= group (the =v= collision), and the copy-up / copy-down slots (=f p= / =f d= proposed). The table above is a proposal; your call on the letters.
6. *Picker label wording* — exact strings for local / published / native-Linear entries.
+7. *Copy-up naming:* is =pearl-copy-view-to-linear= the user-facing primary (with =pearl-publish-local-view= the alias), or the reverse? "Copy up / copy down" is symmetric and matches how you described it; "publish" is the more conventional word for pushing something to a shared place. Recommend leading with =pearl-copy-view-to-linear= for symmetry and keeping =pearl-publish-local-view= as the alias.
+8. *Copy-down tracking:* the recommendation is a *fork* — the new local view carries no =:linear-view-id=, only optional =:copied-from-view-id= provenance, so editing and renaming it never fights a sync link. The alternative is to track the source so copy-up later updates that same Linear view. Recommend fork (you copy down to diverge; copy-up afterward makes a new tracked Linear view). Confirm.
* Acceptance criteria
- Every view operation (create, edit, run, delete, publish) is reachable under the verb a user would predict, all hanging off the =view= noun.
- =pearl-saved-queries= and every renamed command keep working via obsolete aliases; an existing =init.el= needs no edit.
- The picker, transient, keymap help, prompts, README, and docstrings all speak =filter= / =local view= / =Linear view=; "saved query" appears only in the obsolete-alias declarations.
-- =pearl-create-local-view= and =pearl-edit-local-view= exist and round-trip a local view (create → edit → run → publish → delete) with tests.
+- =pearl-create-local-view= and =pearl-edit-local-view= exist and round-trip a local view (create → edit → run → copy up → delete) with tests.
+- Copy down works: a representable Linear view reverse-compiles into an editable local view, and the round-trip (authoring plist → =build-issue-filter= → reverse-compile → identical plist) holds for every dimension in the table, with Normal/Boundary/Error tests.
+- Copy down refuses, with a message naming the unsupported construct, for a Linear view whose filter uses OR logic, an unmodeled dimension, or an operator outside the authoring set — and never produces a lossy local view.
+- Copy up (=pearl-copy-view-to-linear=) creates a tracked Linear view on first copy and updates it in place on re-copy, unchanged from the current sync behavior.
- Full ERT suite green, =make lint= and byte-compile clean.
* Implementation phases (commits)
@@ -186,12 +231,13 @@ Recommend deferring pull to vNext and shipping the rename + create + edit + publ
2. *Create + edit* — =pearl-create-local-view=, =pearl-edit-local-view=, builder extraction shared with =pearl-list-issues-filtered=. Tests. (=feat:=)
3. *Keymap + transient reshape* — the view noun under each verb; help labels. Tests for the keymap. (=feat:=)
4. *Picker labels* — reword =pearl--pick-source-candidates= / scope-label; tests. (=refactor:= / =feat:=)
-5. *README + package-summary* — document filter / local view / Linear view, the git-style lifecycle, and the create/edit/run/publish/delete surface. (=docs:=)
-6. *vNext (separate spec or follow-up):* =pearl-pull-linear-view= + the reverse-compile.
+5. *Copy up* — present the existing publish/sync as =pearl-copy-view-to-linear= (alias over =pearl-publish-local-view=); transient + keymap slot; no behavior change. (=feat:= / =refactor:=)
+6. *Copy down — the reverse-compile* — =pearl--reverse-compile-issue-filter= (inverts every shape in the dimension table; refuses outside it), =pearl-copy-view-from-linear= (fetch =filterData=, reverse-compile, save as a forked local view), keymap + transient slot. Round-trip + refusal tests are the load-bearing coverage. (=feat:=)
+7. *README + package-summary* — document filter / local view / Linear view, the git-style lifecycle, the copy-up / copy-down directions and the representability boundary, and the full create/edit/run/copy/delete surface. (=docs:=)
* Out of scope (vNext)
-- =pearl-pull-linear-view= and the =IssueFilter= reverse-compile.
-- Project / initiative scope on publish (still team/personal only, per =saved-query-sync-spec.org=).
-- OR-logic local views.
+- Project / initiative scope on publish / copy-up (still team/personal only, per =saved-query-sync-spec.org=).
+- OR-logic and the richer =IssueFilter= constructs in local views (operators, unmodeled dimensions). Expanding the authoring model is its own spec; until then, copy-down refuses the Linear views that use them rather than copying them lossily. Growing the authoring model shrinks the refused set.
+- An opaque, run-only copy-down for non-representable Linear views (rejected in favor of refuse-don't-guess; see Open question 3).
- Syncing =:sort= / =:order= to Linear.