aboutsummaryrefslogtreecommitdiff
path: root/docs/issue-query-spec.org
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
commitb081d62276378b3168c92c06153fd59db0589535 (patch)
tree9be7f7d22e0c9b4a73432fe744c09bb456c671a9 /docs/issue-query-spec.org
downloadpearl-b081d62276378b3168c92c06153fd59db0589535.tar.gz
pearl-b081d62276378b3168c92c06153fd59db0589535.zip
feat: pearl — manage Linear issues from org-mode
Pearl fetches Linear issues into an org file and syncs edits back. It covers list / custom views / saved queries, per-issue and bulk rendering with comments inline, conflict-aware sync of descriptions, titles, and comments, field commands for priority / state / assignee / labels, and a transient dispatch menu. The render folds to a scannable outline and nests issues under a sortable parent. Based on and inspired by Gael Blanchemain's linear-emacs.
Diffstat (limited to 'docs/issue-query-spec.org')
-rw-r--r--docs/issue-query-spec.org258
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.