From 15ce896744a8095c221bdeaf887f6311f9a94526 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Wed, 27 May 2026 21:21:51 -0500 Subject: docs: add the issue-sources spec (favorites, views, filters) I drafted a design for unifying Pearl's fetch surface around sources: read the viewer's Linear favorites and run them by type-dispatch behind one pearl-pick-source picker, with the per-dimension filters as the primitives favorites resolve into and the ad-hoc builder as the exploratory fallback. It fixes the by-project me-lock and resolves label and user favorites by stable id, which needs two small filter-compiler forms (:label-id, :assignee-id). The Linear favorites schema is verified against the live API and Codex's review is incorporated, so the spec is Ready. --- docs/issue-sources-spec.org | 143 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/issue-sources-spec.org diff --git a/docs/issue-sources-spec.org b/docs/issue-sources-spec.org new file mode 100644 index 0000000..101e13a --- /dev/null +++ b/docs/issue-sources-spec.org @@ -0,0 +1,143 @@ +#+TITLE: pearl — Issue Sources: Favorites, Views, and Filters Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-27 +#+STARTUP: showall + +* Status + +*Review incorporated (Codex, 2026-05-27); rubric → Ready.* The favorites schema is verified against the live API and folded in (resolving the one blocking finding), all five high-priority findings are dispositioned, and the Issue / Label / User identity questions are decided. Near-zero engine work remains: two small filter-compiler forms (=:label-id=, =:assignee-id=) for rename-proof favorite resolution; everything else is command surface over the existing async query/render path. Modified and rejected review points are in *Review dispositions* at the bottom. + +* Problem + +Pearl's fetch surface is fragmented and me-centric, and it ignores the curation the user has already done inside Linear. + +- =pearl-list-issues= is "my open issues." =pearl-list-issues-by-project= sounds like "the project's issues" but is hardcoded to =:assignee :me=, so it silently shows only the viewer's issues in the project, never the whole project. A command named for a non-assignee dimension that injects assignee=me is misleading (same class as other "looked functional, surprised the user" defects). +- There is no way to see all open issues in a project, or another person's issues, without hand-writing a local saved query or walking the five-prompt ad-hoc builder, and the builder's assignee prompt only offers "me / any", so a *specific other person* is not reachable through it at all. +- The user curates rich filters in Linear's UI as Custom Views and stars them (and projects, labels, cycles, people) as *favorites*. Pearl can run a Custom View by id but cannot read the viewer's favorites, so the curation already done in the far better filter UI is invisible to Pearl. The goal: build the common views once in Linear, favorite them, and have Pearl mirror them, which can remove most of the need for ad-hoc "by X" fetches. +- Local =pearl-saved-queries= solve the "reuse a filter" need but require editing Lisp/Customize and overlap heavily with favorited Custom Views. + +* Current state + +- =pearl-list-issues [project-id]= builds =(:assignee :me :open t [:project P])=. Me-locked even with a project. +- =pearl-list-issues-by-project= picks a team then a project and calls =pearl-list-issues=; inherits the me-lock. +- =pearl-list-issues-filtered= / =pearl--read-filter-interactively= is the ad-hoc builder: team, open?, state, project, labels, assignee (me / any only). Offers to save the result as a local saved query. +- =pearl-run-saved-query= runs a named entry from =pearl-saved-queries= (local Lisp, persisted via Customize). +- =pearl-run-view= runs a Linear Custom View by id (server applies the view's filter). +- =pearl--build-issue-filter= compiles =:assignee :me= (isMe), =:assignee ""=, =:project=, =:team=, =:labels= (by name), =:state=, =:state-type=, =:priority=, =:cycle=. It does *not* yet compile label-id or assignee-id forms (added by this spec, see below). +- *Source model (corrected per HP4).* A rendered file's =#+LINEAR-SOURCE= holds one of exactly two persisted kinds: =:type filter= or =:type view=. =pearl-run-saved-query= renders as a =:type filter= source with the query's name (and =:sort=/=:order=) copied into the header. =pearl-refresh-current-view= accepts only =filter= and =view=. *No =:type saved-query= or =:type favorite= exists, and none is added* — refresh must depend on the stable source descriptor in the file, never on the favorites list or the saved-query store. +- *Favorites: not integrated.* No query of the viewer's favorites. + +* Proposed design + +The unifying idea: everything Pearl fetches is a *source*. Favorites are the curated front door; the per-dimension filters are the primitives favorites resolve into; the ad-hoc builder is the exploratory fallback. A favorite is a *picker entry*, never a persisted source — when chosen it resolves to a concrete =:type filter= or =:type view= source before rendering, so refresh stays stable. + +** Verified favorites schema (2026-05-27, live API) + +Each =Favorite= node carries =type= (the dispatch key), =title= (a ready display label, so the picker needs no per-entity lookup for its text), =url= (the browser fallback), =sortOrder= (Linear's favorite ordering), and a typed entity reference. The query Pearl issues (ids only — see HP3 decision: resolve by stable id, not name/email): + +: query { favorites { nodes { id type title url sortOrder +: customView { id } project { id } cycle { id } +: label { id } user { id } issue { id identifier } team { id } } } } + +Introspection of the =Favorite= type confirmed the favoritable entity fields: =issue=, =project=, =cycle=, =customView=, =label=, =projectLabel=, =user=, =team=, =document=, =initiative=, =dashboard=, =pullRequest=, =release=, =releaseNote=, =facet=, =predefinedViewType=, =projectTab=, =customer=, plus convenience fields =title=, =url=, =detail=, =color=, =icon=, =folderName=, =owner=, =sortOrder=. Craig's current 9 favorites span customView / project / issue; the schema supports the rest. + +Pagination: the =favorites= connection is paginated like other Linear connections. Page it for correctness (reuse the issue pager's shape). Low-risk in practice — favorites are typically a single page — but a partial list must not look complete. + +** Favorite resolution table + +=pearl--normalize-favorite= turns each node into =(:kind KIND :title T :url U :sort-order N :id ID [:identifier IDENT])=. =pearl--favorite->source= then resolves it: + +| favorite =type= | required API fields | v1 action | persisted source | +|----------------------+------------------------+--------------------+----------------------------------------------------| +| =customView= | =customView.id=, title | run the view | =(:type view :name T :view-id ID)= | +| =project= | =project.id=, title | filter | =(:type filter :name T :filter (:project ID :open t))= | +| =cycle= | =cycle.id=, title | filter | =(:type filter :name T :filter (:cycle ID :open t))= | +| =label= / =projectLabel= | =label.id=, title | filter | =(:type filter :name T :filter (:label-id ID :open t))= | +| =user= | =user.id=, title | filter | =(:type filter :name T :filter (:assignee-id ID :open t))= | +| =team= | =team.id=, title | filter | =(:type filter :name T :filter (:team ID :open t))= | +| =issue= | =issue.identifier=, url | browser-only (HP2) | none — opens =url=, renders no source | +| anything else | =url=, title | browser-only | none — opens =url= | + +Refresh: the persisted =filter= / =view= source re-runs its concrete query; refresh never re-reads the favorites list. Issue and non-list favorites persist nothing (they only open a URL), so there is nothing to refresh. + +The two filter forms the compiler must grow (HP3): =:label-id ID= → =labels.some.id.in [ID]=, =:assignee-id ID= → =assignee.id.eq ID=. The existing name/email forms (=:labels=, =:assignee ""=) stay for the ad-hoc builder; favorites use the rename-proof id forms. + +** Unified source picker (the everyday entry point) + +=pearl-pick-source= fetches the viewer's favorites and lists them together with the local saved queries in one =completing-read=, each candidate an attached =(display . plist)= pair so dispatch reads the plist rather than parsing the display string: + +: [view] Active sprint bugs +: [project] Orchestration Dashboard +: [user] Vrezh Mikayelyan +: [label] security +: [saved] My open work + +Picker rules (HP5): + +- *Ordering:* favorites first in Linear's =sortOrder=, then local saved queries alphabetically. +- *Display:* =[type] Title=. Duplicates are disambiguated by the =[type]= tag; only a residual exact collision appends a short id/url suffix, so labels stay clean. +- *Favorites-fetch failure ≠ empty:* on a request/GraphQL error, message the failure and fall back to listing saved queries; never let a failed fetch masquerade as "no favorites". +- *No runnable sources at all:* a clear =user-error= naming the setup ("No favorites or saved queries — star views in Linear, or set =pearl-saved-queries="). + +Selecting a list-capable entry resolves it to a source and renders into the active file through the existing query/render path. Selecting an Issue or non-list favorite calls =pearl--open-favorite-url= (which refuses with a clear message if the URL is nil rather than calling =browse-url= on nil) and messages "Opened favorite in browser: ". =pearl-run-view= and =pearl-run-saved-query= remain as direct commands; =pick-source= is the discoverable superset. + +** The by-X dimensions as primitives + +The favorite dispatch and the builder resolve into the same per-dimension filters. Two pieces: + +- *Fix by-project*: drop the =:assignee :me= lock so a project fetch means the whole project, and rename the source from "My open issues in project" to "Project issues: <name>". =pearl-list-issues-by-project= stays as a thin command and as the Project-favorite dispatch target. "My issues in a project" is a two-dimension pin (project + me) and lives in the builder — =pearl-list-issues= (l) already owns the me-slice. +- *Assignee / label by stable id*: the favorite path resolves users and labels by id (above). No new standalone by-assignee / by-label command — a person or label worth re-fetching gets favorited; a one-off goes through the builder. + +We do *not* add standalone =by-label= / =by-cycle= / =by-team= / =by-state= / =by-priority= commands in v1. + +** Ad-hoc builder enhancement (the exploratory fallback) + +=pearl--read-filter-interactively='s assignee prompt becomes "me / a chosen member / any" instead of "me / any". Picking a member completes over the team's members and passes their id (=:assignee-id=) for consistency with the favorite path. This makes another-person's-issues and every assignee combination reachable without a dedicated command. + +** Sort/order, caching, and exposure + +- *Sort/order (MP3):* favorite-derived filter sources carry no explicit =:sort= / =:order= and use the same default ordering as =pearl-list-issues=. Custom View favorites use the server's view order. Saved-query favorites keep their own =:sort=/=:order=. The user can sort the active file afterward. +- *Caching (MP1):* v1 fetches favorites fresh on each =pearl-pick-source= run; no favorites cache. If one is added later, =pearl-clear-cache= must clear it. +- *Exposure (MP2):* =pearl-pick-source= is added to =pearl-menu= (Fetch group) and to =pearl-fetch-map= at =s= (so =C-; L f s=), labeled "pick source". The existing direct commands stay. + +* Agreed decisions (v1) + +1. Favorites are read from Linear and run by type-dispatch to the matching fetch; favorites are picker entries, never persisted sources. +2. =pearl-pick-source= lists favorites (by =sortOrder=) + local saved queries (alphabetical), dispatching from an attached candidate plist. +3. =pearl-list-issues-by-project= drops the =:assignee :me= lock and renames its source to "Project issues: <name>". Breaking change; free (no users). +4. Label and User favorites resolve by *stable id* — the compiler grows =:label-id= and =:assignee-id= forms. Name/email forms remain for the builder. +5. Issue favorites are *browser-only* in v1 (open the URL); no =:type issue= source is added. +6. Non-issue favorites open in the browser with a message; a nil URL refuses cleanly. +7. The builder's assignee prompt gains a specific-member option (me / member / any), resolving by id. +8. Source model unchanged: only =:type filter= and =:type view= persist; refresh runs the resolved source, never the favorites list or saved-query store. +9. No favorites cache in v1 (fresh fetch); favorite-derived filters carry no explicit sort/order; =pearl-pick-source= exposed in menu + =f s=. +10. Local =pearl-saved-queries= are retained alongside favorites and surfaced in the same picker. + +* Files touched + +- =pearl.el=: =pearl--favorites-async= (paged query) + =pearl--normalize-favorite= + =pearl--favorite->source= + =pearl--open-favorite-url=; =pearl-pick-source= (candidate build + =completing-read= + dispatch); two compiler forms =:label-id= / =:assignee-id= in =pearl--build-issue-filter= (+ validation); fix =pearl-list-issues= / =pearl-list-issues-by-project= (drop me-lock, rename source); the builder's assignee prompt gains the member option; menu (=pearl-menu= Fetch group) and =pearl-fetch-map= (=s=) entries. +- Tests: a favorites fixture shaped exactly like the verified response (including a non-issue type and nullable url); =pearl--normalize-favorite= per type; =pearl--favorite->source= dispatch (each type → exact persisted source plist); the new compiler forms; candidate-builder (mixed favorites + saved queries, duplicate names, deterministic ordering); favorites-failure fallback (saved queries still appear); refresh stability (a Project/Label/User/Cycle favorite source refreshes without calling the favorites API); by-project all-assignees regression; builder member-assignee path; menu/keymap exposure (=tests/test-pearl-menu.el=, =tests/test-pearl-keymap.el=). +- =README.org=: a "Sources" section — favorites, Custom Views, saved queries, ad-hoc filter — with =pick-source= as the front door. + +* Migration + +Additive. =pearl-list-issues-by-project='s scope change (me → all assignees) and source-name change are the only behavior changes; no users, no migration. The new =:label-id= / =:assignee-id= compiler forms are additive (existing name/email forms unchanged). Existing local saved queries keep working and appear in the new picker. No defcustom removed; =pearl-saved-queries= retained. + +* Review dispositions + +Modified or rejected review points; everything else accepted as written. + +- *HP3 (modified).* The review offered label-id-or-name and assignee-id-or-email. I chose id-based for *both*, accepting the small compiler addition (=:label-id= / =:assignee-id=). Favorites are persisted, so resolution must be rename-proof; id also sidesteps the open question of whether =user.email= is reliably present on a favorite payload. The name/email forms stay for the ad-hoc builder, where the user is choosing live. +- *HP5 duplicate display (modified).* The review suggested disambiguating duplicate names with a URL/id suffix in the label. I disambiguate by the =[type]= tag first and append a suffix only on a true residual collision, to keep the completion list readable rather than suffixing every entry. +- *Pagination (clarified, not gated).* Adopted the review's "paginate if the connection paginates" for correctness, but recorded it as a low-risk path — favorites are effectively single-page in practice — so it does not gate v1. + +No findings rejected outright. HP1 (verify schema) is resolved by the live probe; HP2 / HP4 / MP1 / MP2 / MP3 and the architecture, robustness, and test-strategy observations are accepted and woven into the body above. + +* vNext / out of scope + +- Writing favorites from Emacs (star / unstar). v1 is read + run only. +- A =:type issue= single-issue active-file source, if rendering one favorited issue in the file (rather than the browser) proves wanted. +- Standalone by-label / by-cycle / by-team / by-state / by-priority commands, if favorites + the builder are not enough. +- Merging or sunsetting local =pearl-saved-queries= after favorites dogfooding. +- Favorites cache with TTL and =pearl-clear-cache= integration. +- Shared / team favorites, if Linear distinguishes them from the viewer's. -- cgit v1.2.3