aboutsummaryrefslogtreecommitdiff
path: root/docs/issue-sources-spec.org
blob: a4374c37b2daeb7d566158a0df9b8dbbca83a476 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
#+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 } projectLabel { id } document { id } dashboard { id } }
:   pageInfo { hasNextPage endCursor } } }

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 :id ID :url URL)=             |
| =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=              | =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))= |
| =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.