From 55fa2ffe2527ab6a9d93da21b153b26bc803ffda Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Mon, 1 Jun 2026 15:03:08 -0500 Subject: docs: add bidirectional copy to the local/Linear views spec 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. --- docs/local-and-linear-views-spec.org | 98 ++++++++++++++++++++++++++---------- 1 file changed, 72 insertions(+), 26 deletions(-) (limited to 'docs/local-and-linear-views-spec.org') 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. -- cgit v1.2.3