aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/issue-sources-spec.org143
1 files changed, 143 insertions, 0 deletions
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 "<email>"=, =: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 "<email>"=) 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 <type> favorite in browser: <title>". =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.