#+TITLE: pearl — Issue Query & Saved Reports Spec #+AUTHOR: Craig Jennings #+DATE: 2026-05-23 #+STARTUP: showall * Status *DRAFT — review incorporated (2026-05-23), awaiting final go-ahead.* Design proposal; nothing in =pearl.el= has changed. The v1 scope is now decided (see [[*Agreed v1 decisions][Agreed v1 decisions]]); deferred items are in [[*vNext][vNext]]. Modifications/rejections of review recommendations are documented in [[*Review dispositions][Review dispositions]]. Companion: [[file:issue-representation-spec.org][issue-representation-spec.org]] covers how an issue is *rendered and edited* in org once fetched. This doc covers *which* issues get fetched and *where* they land. They meet at the shared org→Linear write path and the single active-file output model. * Problem Today the package fetches one thing: issues assigned to me, optionally narrowed to a single project. Everything else — by status, by project regardless of assignee, by project + status, by label, priority, assignee, cycle — has no path, and there's no way to name a query and run it again. The ask, verbatim: - all open issues assigned to me - all open issues from a project - all open issues in a particular status - all open issues in a particular status in a particular project - many of these should be saved preferences - general enough to also cover labels, priorities, etc. A follow-up reframed the "saved preferences" half: Linear already has *Custom Views* (saved filters in the UI). Rather than invent a parallel local-only "reports" concept, read the user's existing views, run them from Emacs, and (later) push our own filters up. The answer (verified below) is largely yes. So this is one general filter model plus saved entry points on top of it — not a command per ask. * Current state (what exists today) Grounded in =pearl.el= (line refs drift): - *One query shape, hardcoded to "me".* All fetches go through =--get-issues-page-async= (l.368), sending =GetAssignedIssues= against =viewer { assignedIssues(...) }= (l.377-433). No use of the top-level =issues(filter:)= query. - *The only filter is project* — =filter: { project: { id: { eq: $projectId } } }=. No assignee/state/label/priority/team/cycle filter anywhere. - *State filtering is client-side and coupled to rendering.* =pearl-issues-state-mapping= (l.94) doubles as a global include-filter: only issues whose state is in the mapping get written (l.106-108). Adding a state mapping silently changes which issues appear. - *Pagination* is =first: 100= + =after:= cursor, capped at =pearl-max-issue-pages= (l.125, default 10). - *Output is single-file, single-title.* =--update-org-from-issues= (l.1201) writes =pearl-org-file-path= (l.87) with a hardcoded =#+title: Linear issues assigned to me= (l.1182). The sync hook matches the buffer against the hardcoded regex =linear\.org$= (l.956), independent of the defcustom. - *Name→ID resolution* exists for teams (=--get-team-id-by-name=, l.745, case-sensitive) and states (=--get-state-id-by-name=, l.709, per-team, case-insensitive). Projects have no name→ID helper. Labels, assignees, cycles: none. - *Saved filters / reports: none.* The fetch layer is the constraint: it can only ask Linear one question. Replacing that one hardcoded query with a general one is the spec's center of gravity. * Linear's API: what makes a general model possible The top-level =issues(filter: IssueFilter, first:, after:)= query plus the composable =IssueFilter= input type cover every item in the ask: | Ask | IssueFilter fragment | |---------------------------+----------------------------------------------------------| | assigned to me | =assignee: { isMe: { eq: true } }= | | assigned to a person | =assignee: { email: { eq: "x@y.com" } }= | | from a project | =project: { id: { eq: $projectId } }= | | in a status (by name) | =state: { name: { eq: "In Progress" } }= | | open (not done/cancelled) | =state: { type: { nin: ["completed", "canceled", "duplicate"] } }= | | in a team | =team: { key: { eq: "ENG" } }= | | with a label | =labels: { some: { name: { eq: "bug" } } }= | | by priority | =priority: { eq: 2 }= (0 none,1 urgent,2 high,3 med,4 low) | | in a cycle | =cycle: { id: { eq: $cycleId } }= | Two facts make "general enough" tractable: =IssueFilter= AND-s sibling fields and composes with =and=/=or= (so "open + status + project" is three sibling fields in one object); and workflow-state =type= (=triage / backlog / unstarted / started / completed / canceled / duplicate=, verified — seven values) is the workspace-independent "open" primitive, where "open" excludes =completed=, =canceled=, and =duplicate=. So one query (=issues(filter:)=) plus a Lisp→=IssueFilter= compiler covers it. * Linear Custom Views (verified against the published schema) Linear's product "Custom Views" are fully API-accessible. Verified facts (=linear/linear= master GraphQL schema): - *Read views:* =customViews(filter: CustomViewFilter, first:, after:, ...)= → =CustomViewConnection=; single via =customView(id)=. Each carries =name=, =description=, =team= (null = workspace-wide), =owner=, =creator=, =shared=, =icon=, =color=. - *Run a view server-side:* =CustomView.issues(filter: IssueFilter, first:, after:, ...)= resolves the view's own filter on Linear's side. We pass the view id and paginate — no local filter translation. - *Write views:* =customViewCreate/Update/Delete=; create input requires =name=, optional =filterData: IssueFilter=, =teamId=, etc. (vNext — see decisions.) - *No "default view" in the API.* "Default" is a UI concept only; a default must be a local preference naming a view. *Filter-format asymmetry (the crux).* On *write*, =CustomViewCreateInput.filterData= is typed =IssueFilter= — the same type Layer 1 compiles to. On *read*, =CustomView.filterData= is an opaque =JSONObject!=; the schema does *not* guarantee it round-trips as a re-usable =IssueFilter=. *Conclusion: never re-execute a fetched =filterData= locally.* Use the server-side =CustomView.issues= connection to run a view. (These findings are an implementation prerequisite to re-verify — see below.) * Agreed v1 decisions Settled in the 2026-05-23 review. These are no longer open. 1. *Active-file output model.* One configured =pearl-org-file-path= shows *one active view/query at a time*. Running a different saved query or Custom View *replaces* the file contents after dirty-buffer/conflict checks. One Linear issue appears in exactly one place in the active view. (Resolves the output-model question, which gates user-visible multi-query commands — so it's decided up front, not deferred.) 2. *Stable IDs in saved queries.* Saved local queries store stable Linear IDs; human names/keys are display metadata only. Interactive prompts show names; the compiled query executes by ID wherever the API supports it. 3. *=pearl-list-issues= means "my open issues"* — =(:assignee :me :open t)=. No back-compat constraint (no users yet); this is the cleaner default. 4. *Local saved queries are AND-only in v1.* OR is vNext; users needing OR create a Linear Custom View and run it from Emacs. 5. *Sort/order in the query model.* Local saved queries support explicit =:sort= and =:order=, defaulting to =updated= / =desc=. Server-side ordering is limited to Linear's public =orderBy: PaginationOrderBy= — =createdAt= or =updatedAt=, recency-descending, with no direction argument (verified against the schema; the richer per-field =sort: [IssueSortInput!]= arg is marked =[INTERNAL]= and unstable, so v1 avoids it). So =:sort updated= / =:sort created= map to =orderBy= server-side; any other sort field (priority, title, …) or an explicit ascending =:order= is a deterministic client-side sort after fetch (so refresh doesn't reorder headings into noise). 6. *Custom Views are read-only/run-only in v1.* Create/update/delete and pushing local queries up as views are vNext. * Implementation prerequisites — schema verification (complete) Both the published-schema pass and the live run are done, so this prerequisite is cleared. *Published-schema pass* (2026-05-23, against =linear/linear= master =schema.graphql=). Confirmed: - =issues(filter:)= takes every planned fragment; =IssueFilter= field/sub-filter names check out — assignee.isMe/email, state.name/type, project.id, team.key, labels.some/every (*no* =none=), priority =NullableNumberComparator= (eq/in/nin), cycle.id, and =and=/=or=. - Workflow-state =type= values: triage/backlog/unstarted/started/completed/canceled/*duplicate* (seven, not six). - Issues ordering is =orderBy: PaginationOrderBy= = createdAt/updatedAt only (see decision 5; the per-field =sort= arg is =[INTERNAL]=). - =CustomView.issues= → =CustomViewConnection= (nodes/pageInfo). =commentCreate(input: CommentCreateInput!)= → =CommentPayload= (comment/success/lastSyncId), input body+issueId(+parentId), all input fields nullable. =Comment.user= is *nullable* (bot/integration comments — see the representation spec). *Live run* (2026-05-23, deepsat workspace, via the package's own GraphQL layer with the key from =.authinfo.gpg=). Confirmed: =customViews= returns both shared *and* personal views for the key (6 shared + 1 personal); =issues(filter:)= with =assignee.isMe= + =state.type nin [completed,canceled,duplicate]= + =orderBy: updatedAt= returns the right open issues; =customView.issues= runs a view's filter server-side; comment read works; =commentCreate= on a test issue succeeds (test comment deleted after). The committed fixtures in =tests/testutil-fixtures.el= stay *synthetic* — real workspace data doesn't belong in a public repo — and were confirmed to match the live shapes. =CustomViewCreateInput.filterData= = =IssueFilter= stays unverified-by-use until view-write lands (vNext). * Proposed design ** Layer 1 — the filter DSL (+ validation) *Authoring form* (convenient, names allowed; used for ad-hoc filters and hand-written queries). Each key optional; present keys AND-ed: #+begin_src elisp (:assignee :me ; :me | "email@addr" | nil :open t ; t => state.type nin [completed,canceled,duplicate] :state "In Progress" ; state name (needs team context) or :state-type :state-type ("started" "unstarted") ; direct workflow-state type control :project "Foo" ; project name (needs team context) or id :team "ENG" ; team key or name :labels ("bug" "p1") ; label names -> labels.some :priority high ; symbol (none/urgent/high/medium/low) or 0-4 :cycle "Cycle 12" ; id, current/upcoming symbol, or team+number/name :sort updated :order desc) #+end_src *Stored form* (saved queries): the resolved-to-IDs filter plus display metadata plus sort/order. The interactive builder resolves names→IDs at save time; the stored query executes by ID. *Selector semantics* (names are ambiguous — projects/labels/states/cycles can collide across teams): - =:team= — key or ID; ID internally. - =:project= — ID, or =(team . name)= for disambiguation. - =:state= — state type, or state ID/name *with team context*. - =:labels= — names only when team/project context removes ambiguity; otherwise prompt on multiple matches. - =:cycle= — ID, =current=/=upcoming= symbols, or team + cycle number/name. - =:open= and explicit =:state=/=:state-type= — if both set, the explicit state wins (it's more specific). =:open t= ≡ =type nin [completed,canceled,duplicate]= ≡ type in =triage/backlog/unstarted/started=. *Validation.* =pearl--validate-issue-filter= runs before compilation: rejects unknown keys, bad priority symbols, incompatible combinations, ambiguous fields lacking team context, empty strings, unsupported value shapes — with clear error messages (tested). A plist silently accepts typos; validation is what makes a user-facing saved-query defcustom safe. *Compiler.* =pearl--build-issue-filter (plist)= → the GraphQL =filter:= object, via small pure predicate helpers (=--eq=, =--nin=, =--some=, =--compile-priority=, =--compile-state-filter=), each unit-tested. Adding a dimension is a clause here, not a new command. ** Layer 2 — general fetch over a normalized pager A single =pearl--page-issues= helper accepts a (query-builder . extractor) pair and returns *normalized* issue objects, owning the page cap, vector→list coercion, progress messages, and partial-error behavior in one place. Two callers: - =--query-issues-async (filter)= → top-level =issues(filter:)=. - =--query-view-async (view-id)= → =customView(id) { issues(...) }= (server applies the view's filter). The existing assigned-issues fetch becomes the first caller (=filter = {assignee:{isMe:{eq:true}}}=), collapsing the two hardcoded query variants. *Error shape.* Internal callbacks distinguish *no results* / *request failed* / *invalid filter* rather than collapsing all to =nil=; user commands collapse them to messages. (V1 minimum: enough to tell an empty result from a failure.) ** Layer 3 — saved reports (Linear views first, local queries as complement) *Read side (main path).* =pearl-run-view= does =completing-read= over the user's =customViews= (cached), then fetches via the view primitive. "Run one of my saved Linear reports from Emacs" with zero local config. *Local saved queries (complement).* A defcustom of named filters for ad-hoc / Emacs-only reports, storing IDs + display metadata + sort/order (Agreed decisions 2, 5): #+begin_src elisp (defcustom pearl-saved-queries '(("My open work" :filter (:assignee :me :open t) :sort updated :order desc)) "Named local issue queries. Stored form keeps resolved IDs; display names are metadata. AND-only in v1; use a Linear Custom View for OR logic." ...) #+end_src *Default report.* No API field, so a local =pearl-default-view= names a view (or saved query) run by the bare zero-arg command. (See [[*Review dispositions][Review dispositions]] on why a separate =default-issue-filter= is *not* added.) ** State mapping vs filter — break the coupling Split the two jobs =issues-state-mapping= conflates today: - =pearl-state-to-todo-mapping= — render/sync Linear state ↔ org TODO keyword. Rendering only. - *Query filters* — inclusion/exclusion (=:open=, =:state=, =:state-type=). A filter, not a mapping. So adding a state-to-TODO mapping no longer changes which issues appear. ** Output model — concrete One active file (=pearl-org-file-path=). Running a view/query replaces its contents after the dirty-buffer guard (and the representation spec's conflict check). The *file header* records the active source so refresh re-runs it without asking: - query/view name, - run timestamp, - filter summary, - issue count, - truncation warning if the page cap was hit (also =message='d), - source: local query vs Linear custom view. This makes reports self-describing and bug reports legible. The sync hook must recognize the configured =org-file-path=, not just =linear\.org$=. ** Orientation & refresh commands - =pearl-refresh-current-view= — re-run the active source from the header. - =pearl-refresh-current-issue= — re-fetch the issue at point. - =pearl-open-current-view-in-linear= — if the source view has a URL. ** Caching Caches for teams/states/projects/labels/views power both filters and interactive completion. V1 cache control: - =pearl-clear-cache= command, - a force-refresh argument on interactive selectors, - cache keys that include team ID where relevant. (Automatic TTL is vNext — see [[*Review dispositions][Review dispositions]].) ** Layer 4 — commands - =pearl-list-issues= — zero-arg "my open issues" (Agreed 3), over Layer 2. - =pearl-run-view= — =completing-read= over Linear custom views; run server-side. Main saved-report path. - =pearl-run-saved-query= — pick a local saved query, run it. - =pearl-list-issues-filtered= — build an ad-hoc filter interactively; *complete from fetched* teams/projects/states/labels/cycles (not free text) to avoid typo'd-filter empty-result confusion; optionally save as a local query. - =pearl-list-issues-by-project= — keep; reimplement as a thin =(:project X :open t)= call. (A transient menu is a separate todo task and the natural front door once these exist.) * Phased implementation 1. *Layer 1 + validation + tests.* Pure =--build-issue-filter= + =--validate-issue-filter= + predicate helpers. Normal/Boundary/Error + pairwise over dimension combinations. No API. Lands green. 2. *Layer 2a — normalized pager + =--query-issues-async=.* Reimplement the assigned-issues fetch over it; characterization test proves =list-issues= still works, then flip it to "my open issues" (Agreed 3). 3. *State-mapping/filter split* + project name→ID helper + =list-issues-filtered= (ad-hoc, complete-from-fetched). 4. *Active-file output model* — header metadata, refresh-current-view, sync-hook recognizes the configured path. (Decided up front, implemented here because everything user-visible depends on it.) 5. *Layer 2b + view read* — =--query-view-async=, =customViews= listing/cache, =run-view=, default-view preference. 6. *Local saved queries* defcustom + =run-saved-query= + =:sort=/=:order=. vNext (gated, not in v1): view writes, OR DSL, automatic TTL, per-query/multi-view files. * Test strategy *Pure unit (first):* valid fragments for assignee me/email, open, state name/type, project ID, team key/ID, labels, priority, cycle; AND composition; =:state= vs =:open= precedence; bad keys / unresolvable names raise clear errors; priority symbol/number normalization; AND-only enforced (OR documented unsupported locally). *Query/pagination (request stubs):* top-level =issues(filter:)= uses the compiled variables; pagination follows =hasNextPage=/=endCursor=; page cap reports truncation; a partial error does not masquerade as an empty success; =customView.issues= extracts the same normalized shape. *Command/output:* =list-issues= = my open issues; =list-issues-by-project= is a thin general-query caller; running a saved query/view replaces the active file with accurate header metadata; sync hook recognizes the configured =org-file-path=; a dirty active file is not overwritten. *Fixtures:* small representative JSON for =issues=, =customViews=, =customView.issues=; normalize vectors / =null= / missing optional fields consistently. * Relationship to existing todo.org tasks Supersedes / absorbs three open feature tasks once approved — fold them into the phased plan rather than tracking separately: - =More issue filters (assignee, label, state, cycle)= — this *is* that task, generalized. - =Fetch scope beyond assigned issues= — Layer 2 + the =:assignee= dimension. - =list-issues-by-project= — a thin caller of the general path. * Open decisions None blocking v1 — the six agreed decisions resolved them. Remaining judgment calls are implementation-level (exact ad-hoc prompt flow, header wording) and don't gate the start. * vNext - OR support in the local saved-query DSL. - Interactive sort/order changes (command/menu). - Sync default sort/order back to Linear Custom Views if the API supports it. - Create/update/delete Custom Views from Emacs (workspace-mutating; explicit confirmation + shared/personal prompt). - Optional per-query files or multi-view files — only with demonstrated need *and* a designed duplicate-issue semantics. - Automatic cache TTL. - Batch/staged prefetch for interactive prompts (first team choice scopes and fetches the rest) — a perf refinement; v1 can fetch on demand. * Review dispositions All review recommendations were accepted and incorporated above except the following, modified with reasons: 1. *Cache TTL defcustom → modified (deferred to vNext).* The review recommended an optional TTL defcustom alongside =clear-cache= and force-refresh. For a single-user tool, an automatic TTL adds invalidation complexity (stale-while-revalidate semantics, per-cache tuning) with little benefit over an explicit force-refresh. V1 ships =clear-cache= + a force-refresh arg on selectors; TTL is listed in vNext if a real need appears. 2. *Separate =pearl-default-issue-filter= defcustom → rejected.* The review floated it ("possibly") as the default for the bare command. With Agreed decision 3 fixing =list-issues= to "my open issues" and =pearl-default-view= covering a user-chosen default report, a third default-filter knob is redundant surface area. The two existing mechanisms cover the need. 3. *Structured error propagation → accepted but scoped.* Adopted as a v1 design principle (distinguish no-results / failure / invalid-filter at the internal boundary), but not a full callback-protocol refactor — v1 implements the minimum needed to keep "empty" and "failed" distinct, leaving a richer error type for later if the command surface grows. Everything else — active-file output model, ID-based selectors, state-mapping/filter split, filter validation, sort/order, schema-prerequisite checklist, normalized pager, GraphQL predicate helpers, self-describing headers, refresh/orientation commands, complete-from-fetched prompts, server-side filtering, store-IDs-in-properties, and the full test strategy — was accepted as written.