diff options
Diffstat (limited to 'docs/issue-query-spec.org')
| -rw-r--r-- | docs/issue-query-spec.org | 258 |
1 files changed, 258 insertions, 0 deletions
diff --git a/docs/issue-query-spec.org b/docs/issue-query-spec.org new file mode 100644 index 0000000..75dcc4b --- /dev/null +++ b/docs/issue-query-spec.org @@ -0,0 +1,258 @@ +#+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. |
