diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/issue-comment-editing-spec.org | 117 | ||||
| -rw-r--r-- | docs/issue-conflict-handling-spec.org | 74 | ||||
| -rw-r--r-- | docs/issue-query-spec.org | 258 | ||||
| -rw-r--r-- | docs/issue-representation-spec.org | 230 | ||||
| -rw-r--r-- | docs/issue-sort-order-spec.org | 65 | ||||
| -rw-r--r-- | docs/labels-as-org-tags-spec.org | 81 | ||||
| -rw-r--r-- | docs/todo-keywords-from-workflow-states-spec.org | 209 |
7 files changed, 1034 insertions, 0 deletions
diff --git a/docs/issue-comment-editing-spec.org b/docs/issue-comment-editing-spec.org new file mode 100644 index 0000000..6a1b0c8 --- /dev/null +++ b/docs/issue-comment-editing-spec.org @@ -0,0 +1,117 @@ +#+TITLE: pearl — Comment Editing Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*APPROVED — open questions resolved 2026-05-24 (see [[*Resolved decisions][Resolved decisions]]). Implementation in progress.* Nothing in =pearl.el= had changed at the time of writing. + +Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (rendering + description/title editing) and [[file:issue-query-spec.org][issue-query-spec.org]] (fetching). This doc covers the one editing path those two parked as vNext: editing an existing comment. It reuses their org→Linear write path, their conflict-gate pattern, and the single active-file model. + +* Problem + +Comments are render-and-add only. You can read the thread and post a new comment, but you can't fix a typo in your own comment without leaving Emacs for the Linear web UI. Linear lets a user edit only their own comments, so the feature has to carry a permission check: a comment authored by someone else (or by a bot or integration) must not be editable from Emacs, and the attempt must fail clearly rather than bounce off the server with an opaque error. + +The representation spec already parked this (its decision 2): "editing existing comments is vNext, and then only comments authored by the current Linear user, matching Linear's permissions." This is that vNext. + +* Current state (what exists today) + +- *Fetch.* =pearl--fetch-issue-async= (=pearl.el:~737=) pulls each comment as =id=, =body=, =createdAt=, =user { id name displayName }=, =botActor { name }=, =externalUser { name }=. The single-issue fetch carries comments; the bulk list omits them. +- *Normalize.* =pearl--normalize-comment= (=l.568=) returns =(:id :body :created-at :author)=. The =:author= is the *display name only* — the user's =id= is fetched but dropped. There is no viewer identity anywhere in the package. +- *Render.* =pearl--format-comment= (=l.1612=) renders =***** <author> — <timestamp>= followed by the body (markdown → org). The comment =id= is not written into the org; nothing per-comment is recoverable after render. +- *Add.* =pearl--create-comment-async= (=l.1949=, =commentCreate=) + =pearl--append-comment-to-issue= (=l.1975=). +- *Conflict pattern to reuse.* Description sync (representation spec) hashes the last-fetched body into =LINEAR-DESC-SHA256=, compares last-fetched / current-org / current-remote, and does no-op / push / refuse-on-both-changed. + +Three things are therefore missing for editing: the *viewer's identity*, per-comment *id + author id + provenance* in the org, and a =commentUpdate= write path with the same conflict gate. + +* Proposed design + +** 1. Viewer identity + +Add an async =viewer { id name }= query with a cached id, mirroring the team/state caches: + +- =pearl--viewer-async (callback)= → normalized =(:id :name)=. +- =pearl--viewer-id= → cached id, fetched once per session. +- Add the viewer cache to =pearl-clear-cache=. + +This is the identity the permission check compares against. + +** 2. Retain the comment author id + +Extend =pearl--normalize-comment= to keep =:author-id= (the =user.id=). Bot and external comments have no editable user, so =:author-id= is nil for them — which the permission check reads as "not editable." + +** 3. Per-comment provenance in the org + +To target a comment for =commentUpdate= and to decide editability, each rendered comment heading needs its id, its author id, and a body hash. A small property drawer under each =*****= comment heading, mirroring the issue drawer: + +#+begin_src org +**** Comments +***** Craig — 2026-05-24T10:00:00.000Z +:PROPERTIES: +:LINEAR-COMMENT-ID: <uuid> +:LINEAR-COMMENT-AUTHOR-ID: <user-uuid or empty> +:LINEAR-COMMENT-SHA256: <hash of the last-fetched body> +:END: +The comment body renders here as org, edited in place. +#+end_src + +=org-tidy= folds the drawer the same way it folds the issue drawer, so the thread still reads cleanly. The =SHA256= is the last-fetched-body provenance for the conflict gate, exactly like =LINEAR-DESC-SHA256=. + +** 4. The edit command + +=pearl-edit-current-comment= (name is an open question), run from anywhere inside a comment's subtree: + +1. Locate the enclosing =*****= comment heading and read its drawer. +2. *Permission gate.* If =LINEAR-COMMENT-AUTHOR-ID= is empty or ≠ the viewer id, =user-error= "You can only edit your own comments" and stop. No network call. +3. Render the comment's current org body to markdown (the description sync's org→md path). +4. *Conflict gate* (mirrors description sync, v1 = detect / refuse / message): + - current org-rendered hash = =LINEAR-COMMENT-SHA256= → unchanged → no-op, no API call. + - changed locally, remote unchanged since fetch → =commentUpdate= push. + - both changed (re-fetch the remote comment body; its hash ≠ the stored last-fetched hash) → refuse, report, suggest refresh. +5. On success, update =LINEAR-COMMENT-SHA256= and re-render the comment body from the returned comment. + +** 5. The write path + +=pearl--update-comment-async (comment-id body callback)= over =commentUpdate(id: $id, input: { body: $body }) { success comment { ... } }=, normalizing the returned comment. (*Exact mutation shape to be live-verified during implementation*, the way =commentCreate= and the issue mutations were verified against the real workspace.) + +** 6. Editability highlighting (own = green, others = grey) + +"Comments by other users must not appear editable." The permission gate in step 4.2 enforces the behavior; this section makes it *visible* so a user sees what's editable before trying. + +Each comment heading is colored by editability when the active file is displayed and after every refresh: + +- the viewer's own comments → =pearl-editable-comment= face (green), +- everyone else's, plus bot and external comments → =pearl-readonly-comment= face (greyed, inherits =shadow=, reads as disabled). + +Two custom faces so users can theme them. Because the active file is generated and written to disk, faces can't be stored in the file — they're applied at *display time*. Mechanism (proposed): an overlay pass that runs during render and re-runs on the refresh / find-file hook, reading each comment's =LINEAR-COMMENT-AUTHOR-ID= drawer and comparing it to the cached viewer id. Overlays are preferred over a font-lock matcher because they don't contend with org's own fontification and the highlighted set is small. The viewer id must be resolved before the highlight pass — fetch it alongside the single-issue fetch that already pulls comments, so it's in hand at render. + +** 7. Refresh interaction + +Refreshing the issue (=refresh-current-issue=) replaces the subtree, so an unpushed comment edit would be lost — the same risk description edits already carry. The existing dirty-buffer guard covers it; no new merge logic in v1. + +* Proposed v1 decisions + +1. Only the viewer's own comments are editable. Others' comments (and bot/external comments) refuse with a =user-error=, no network call. +2. Each rendered comment carries a drawer with its id, author id, and last-fetched body hash. +3. Conflict handling is detect / refuse / message — identical to description sync v1. Interactive merge is vNext. +4. Edit-in-place: edit the comment's org body, then run the command from inside the comment subtree (consistent with how descriptions sync). No separate prompt buffer. +5. Comment *deletion* stays out of scope (read / add / edit only). Deletion is its own vNext item if wanted. +6. Editability is shown by color: own comments green, others greyed (decision from the 2026-05-24 review). +7. The edit command is named =pearl-edit-current-comment= and is added to the transient menu under "Issue at point." + +* vNext (out of scope here) + +- Comment deletion. +- Interactive conflict resolution (diff / local-wins / remote-wins) — shared with the description/title conflict vNext. +- Editing via a dedicated prompt buffer instead of in-place. +- Threaded replies (parent comment id). + +* Resolved decisions + +Settled with Craig, 2026-05-24: + +1. *Per-comment drawer* — yes. Each comment heading carries =LINEAR-COMMENT-ID= / =-AUTHOR-ID= / =-SHA256=, consistent with the issue drawer. +2. *Editability visibility* — refuse is enough for behavior, plus color: others' comments render greyed (disabled-looking), the viewer's own render green (see [[*6. Editability highlighting (own = green, others = grey)][Editability highlighting]]). +3. *Command name* — =pearl-edit-current-comment=. +4. *Transient* — yes, add it under "Issue at point" once implemented. diff --git a/docs/issue-conflict-handling-spec.org b/docs/issue-conflict-handling-spec.org new file mode 100644 index 0000000..09acaf6 --- /dev/null +++ b/docs/issue-conflict-handling-spec.org @@ -0,0 +1,74 @@ +#+TITLE: pearl — Interactive Conflict Handling Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*IMPLEMENTED (2026-05-24).* Shipped in two increments: the use-local / use-remote / cancel core, then the smerge rewrite-in-buffer path and the refresh hardening. The open questions are resolved; see "Decisions" at the end. + +Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (description/title editing) and [[file:issue-comment-editing-spec.org][issue-comment-editing-spec.org]] (comment editing). All three share the conflict gate this doc proposes to extend. + +* Problem + +v1's conflict handling is detect / refuse / message: when a description, title, or comment changed both locally and on Linear since the last fetch, the push is refused and a message tells the user to refresh. That protects the remote, but it leaves the user stuck. The only way forward is a manual refresh — which replaces the subtree and *discards the local edit*. So the safe-by-default behavior has a data-loss trap one keystroke away, and no in-Emacs path to actually reconcile the two versions. + +Craig's direction (2026-05-24): keep it simple — offer use-local, use-remote, or rewrite-in-an-Emacs-buffer. Error messages must be descriptive. And there must be a way through that never silently discards the user's input, because that counts as data loss. + +* Current state + +- =pearl--sync-decision= (=pearl.el:~1682=) returns =:noop= / =:push= / =:conflict= from the three-way hash compare (local-rendered vs last-fetched vs current-remote). +- =pearl-sync-current-issue=, =-sync-current-issue-title=, and =pearl-edit-current-comment= all =pcase= on that and, for =:conflict=, just =message= and stop. +- =refresh-current-issue= has a dirty-buffer guard that refuses to refresh when the body has unpushed edits — so a refresh can't clobber silently *today*, but it also can't help resolve; the user has to throw away their edit to move on. + +* Proposed design + +On =:conflict=, instead of only refusing, prompt the user to choose a resolution. One shared helper drives all three call sites (description, title, comment) so the behavior is identical everywhere. + +** The resolution prompt + +=completing-read= (or a transient) with three choices, each with a descriptive label: + +1. *Use local* — push my version, overwriting the remote. Advances the stored hash to the local text. +2. *Use remote* — discard my local edit and take Linear's current version. **Guarded against data loss** (see below): the local text is stashed before it's replaced. +3. *Rewrite in a buffer* — open a reconciliation buffer showing both versions; the user produces the merged text and pushes that. + +A fourth implicit option is always cancel (=C-g=) — leaves everything untouched, same as today's refuse. + +** No data loss — the hard requirement + +"Use remote" and "rewrite" both risk throwing away what the user typed. Before either path replaces the local text, stash it so it's always recoverable: + +- Push the local version onto the =kill-ring= (so =yank= brings it back), and +- write it to a dedicated =*pearl-conflict-backup*= buffer with a heading naming the issue/field and timestamp. + +The stash happens unconditionally on any destructive resolution. The message after "use remote" says where the old text went ("your local version is on the kill-ring and in =*pearl-conflict-backup*="). + +** The rewrite-in-a-buffer flow + +Open a reconciliation buffer prefilled so the user can see and edit both sides. Chosen mechanism (decision 1): *smerge*. Write the two versions as a =<<<<<<< LOCAL / ======= / >>>>>>> REMOTE= conflict and drop the user into =smerge-mode=, so =smerge-keep-current= / =-other= / =-all= and the rest work without custom keys. A short banner names the push/abort keys. (Considered and rejected: a plain two-section buffer — simpler but reinvents conflict navigation; and =ediff= — too heavy for a one-field reconcile.) + +On finish, the reconciled text (markers resolved) is pushed via the same =--update-*= path, and the stored hash advances to it. + +** Descriptive errors + +The conflict prompt and messages name specifics: the field (description / title / comment), the issue identifier, that both sides changed since the last fetch, and the remote's =updatedAt= so the user knows how stale their copy is. No bare "conflict detected". + +* Proposed v1 decisions (this feature) + +1. One shared resolution helper across description, title, and comment. +2. Three resolutions plus cancel: use-local, use-remote, rewrite-in-buffer. +3. Any destructive resolution stashes the local text to the kill-ring *and* a backup buffer first — never discard input. +4. Messages and the prompt are field- and issue-specific. + +* vNext / out of scope + +- Field-level 3-way auto-merge (only the changed lines). +- Conflict resolution for the drawer fields (state/priority/assignee/labels) — those are command-set, not free-text, so they don't have the same merge problem. + +* Decisions (Craig, 2026-05-24) + +1. *Rewrite-buffer mechanism*: =smerge=. Write the two versions as a =<<<<<<< / ======= / >>>>>>>= conflict and drop the user into =smerge-mode=; the =smerge-keep-*= commands work out of the box and the UX matches git muscle memory. No heavy dependency. +2. *Stash location*: kill-ring + a =*pearl-conflict-backup*= buffer. In-memory recovery (yank, or read the named buffer); no file-backup layer in v1. +3. *Default resolution on RET*: cancel. A bare =RET= at the prompt leaves everything untouched, the same as today's refuse — the safest default. +4. *"Use remote" guard scope*: yes. =refresh-current-issue= adopts the same stash-before-replace guarantee, so no refresh path can lose an unpushed edit. (The merge refresh already keeps dirty subtrees rather than overwriting; this hardens the single-issue refresh, which today refuses on a dirty body — it will stash then proceed instead.) 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. diff --git a/docs/issue-representation-spec.org b/docs/issue-representation-spec.org new file mode 100644 index 0000000..912cee9 --- /dev/null +++ b/docs/issue-representation-spec.org @@ -0,0 +1,230 @@ +#+TITLE: pearl — Issue Org Representation & Editing 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. v1 scope is decided (see [[*Agreed v1 decisions][Agreed v1 decisions]]); deferred items in [[*vNext][vNext]]; modifications/rejections in [[*Review dispositions][Review dispositions]]. + +Companion to [[file:issue-query-spec.org][issue-query-spec.org]] (which covers *fetching/filtering*). This doc covers how an issue is *rendered and edited* once it's in org. They share the org→Linear write path and the single active-file output model. + +* Problem + +Open a fetched issue in org and the body below the drawer is empty — and with =org-tidy= folding the drawer, the whole entry looks blank. The one piece of free-text the issue has (its description) isn't missing; it's misfiled into a *property*. Users can't tell what they're allowed to edit. + +Grounded in =pearl--format-issue-as-org-entry= (=pearl.el:1114-1173=): the description is written into a =:DESCRIPTION: |= property as 2-space-indented lines (=l.1154-1158=), inside the drawer; the body after =:END:= (=l.1171=) is empty; =org-tidy= then hides the drawer and the entry reads as blank. That's the root of "I opened the task and there's nothing there / I'm not sure what I can edit." + +* Current rendering (what exists today) + +Each issue is a =***= heading =*** <TODO-state> <priority> <title>= (=l.1146=) plus a drawer carrying =:ID:= (Linear UUID), =:ID-LINEAR:= (the =ENG-123= identifier), =:TEAM:=, =:DESCRIPTION:= (the misfiled body), =:PRIORITY:=, =:LABELS:=, =:PROJECT:=, =:LINK:=, =:PROJECT-ID:= (=l.1148-1171=). Nothing below the drawer. The fetch query pulls =description= (=l.1119=) but *not* comments. State sync resolves team name → ID by network lookup each time (slow, fragile on rename/collision). The renderer strips =[ ]= from titles (=l.1145=) — existing lossy title behavior. + +* Agreed v1 decisions + +Settled in the 2026-05-23 review. + +1. *The org issue body is entirely Linear-owned in v1.* No local-only notes area. The active org file is a synchronized representation of Linear, not a mixed local/remote workspace. +2. *Fetched comments are remote-owned display content.* Users can *add* comments; editing existing comments is vNext (and then only comments authored by the current Linear user, matching Linear's permissions). +3. *New entries use only namespaced =LINEAR-*= properties.* No compatibility layer for the old =:ID:= / =:ID-LINEAR:= shape (no users yet). +4. *Description sync starts as an explicit command only.* Automatic save-triggered description sync is vNext, after no-op detection and conflict handling are proven. +5. *V1 conflict handling is detect / refuse / message.* Interactive diff-merge or local/remote choice is vNext. + +* Content ownership and refresh semantics + +The hard part isn't moving the description — it's distinguishing machine-owned fetched content from user edits once refresh, comments, and sync coexist. v1 makes this simple by fiat (decision 1: the whole body is Linear-owned), but the layout and refresh model still have to be explicit. + +** Generated entry layout + +#+begin_src org +*** TODO [#B] ENG-123 Title +:PROPERTIES: +:LINEAR-ID: <uuid> +:LINEAR-IDENTIFIER: ENG-123 +:LINEAR-URL: https://linear.app/.../ENG-123 +:LINEAR-TEAM-ID: <id> +:LINEAR-TEAM-NAME: ENG +:LINEAR-PROJECT-ID: <id> +:LINEAR-PROJECT-NAME: Foo +:LINEAR-STATE-ID: <id> +:LINEAR-STATE-NAME: In Progress +:LINEAR-ASSIGNEE-ID: <id> +:LINEAR-ASSIGNEE-NAME: Craig +:LINEAR-LABELS: [bug, p1] +:LINEAR-DESC-SHA256: <hash of last-fetched markdown> +:LINEAR-DESC-UPDATED-AT: <remote timestamp> +:END: + +Description text managed by Linear (org-rendered). + +**** Comments +***** <author> — <timestamp> +comment body +#+end_src + +*Store IDs and display names separately* for team, project, assignee, state, labels (and later cycle). Commands display names; they mutate by ID. This kills the per-render network name-lookup. + +*Provenance for the description* lives as a *hash + remote timestamp* in properties — not the full raw markdown. A large multiline markdown property is awkward in org and bad with folding. When the sync/no-op check needs the exact last-fetched markdown, fetch current remote markdown before deciding, or keep it in an internal cache keyed by =LINEAR-ID=. (See [[*Conflict handling][Conflict handling]].) + +** Refresh model — merge by ID, reconciled with the active-file output model + +The query spec's output model says *switching to a different view/query replaces the active file*. This spec's refresh says *don't wholesale-rewrite*. Both hold, for different actions: + +- *Switching source* (run a different view/query) → the issue set changes; replace the file contents after the dirty-buffer + conflict checks. One issue appears in one place. +- *Refreshing the same source* (=refresh-current-view=, =refresh-current-issue=) → *merge by =LINEAR-ID===: update each existing issue subtree in place, add new matches, drop issues no longer in the result. Per subtree, run the conflict check before overwriting a description that was edited locally but not yet pushed. + +A wholesale rewrite on same-source refresh would clobber un-pushed description edits; merge-by-ID + per-subtree conflict check is what protects them. + +* Proposed model — body is editable content, drawer is machine-managed metadata + +Organizing principle: the body holds what a human reads and writes (description, comments); the drawer holds structured fields commands manage. An =org-tidy= user edits body text + runs commands and never touches the drawer. + +** Description → body + +Render the description as the heading body (org-converted — see [[*Markdown vs org — the conversion question][conversion]]). Opening a task now shows its description; the org-tidy blank-entry problem disappears. The body is the editable region; an explicit command (decision 4) pushes edits back, behind the conflict gate. + +** Drawer = command-managed fields + +State (TODO keyword), priority, labels, project, assignee live in the drawer/heading and change via dedicated commands ("Set assignee, priority, labels" task), which resolve names→IDs. =org-tidy= users never need to open the drawer. + +** Comments as a body subtree + +Fetch comments (needs a query change — not pulled today) and render *oldest-first* as =****= → =*****= sub-headings (=<author> — <timestamp>=, body beneath), so the thread reads chronologically and "add comment" appends at the end. =pearl-add-comment= creates a new comment via =commentCreate= and inserts/refreshes the returned comment. Fetched comments are remote-owned (decision 2): editing an existing comment heading does *not* sync back in v1. + +*Comment shape (verified against the published schema).* =Issue.comments= → =CommentConnection= (nodes/pageInfo); each =Comment= has =body= (markdown — runs through the same conversion tier as the description), =createdAt=, and =user=. *=user= is nullable* — comments from integrations or bots have no user, carrying =botActor= / =externalUser= instead. The renderer must fall back to the bot/external actor name (or a literal like "(automation)") for the author rather than assuming a =user.name=. =commentCreate(input: CommentCreateInput!)= returns =CommentPayload= (=comment=, =success=); the input takes =body= + =issueId= (and optional =parentId=), with success checked the same way as issue creation before reporting. + +** Affordance + discoverable commands + +A one-line preamble note (body = description, edit + sync via command; Comments subtree = thread, add via command; fields = drawer, change via commands). But commands matter more than a note — expose discoverable ones that work from *anywhere inside an issue subtree*: + +=pearl-sync-current-issue=, =pearl-open-current-issue=, =pearl-add-comment=, =pearl-set-priority=, =pearl-set-assignee=, =pearl-refresh-current-issue=. + +** Sub-issues (later) + +Optional nested headings; out of scope for v1. + +* Markdown vs org — the conversion question + +Linear stores descriptions/comments as *markdown*; we want *org* in the body. The directions differ in difficulty. + +- *org → markdown (push):* =ox-md= is built in, but it is *not* round-trip-faithful for the subset Linear uses (see [[*ox-md rejected for push][ox-md rejected for push]]). Push is therefore a hand-rolled inverse of the fetch converter. +- *markdown → org (fetch):* no built-in. The only place pandoc is tempting. + +** ox-md rejected for push + +The original recommendation was =org-export-string-as ... 'md= for the push direction. Empirical testing (2026-05-23) of =org→md(md→org(x))= over the conversion matrix showed *zero of nine samples round-trip cleanly*. =ox-md= injects a =# Table of Contents= header, inverts emphasis (org =*italic*= → md =**bold**=), *drops checkbox markers* (=- [x] done= → =- done=), converts fenced code to 4-space indented blocks (losing the language), and reindents lists. + +This breaks the conflict gate two ways: the no-op guard compares =hash(org→md(body))= against the stored =LINEAR-DESC-SHA256= (hash of the last-fetched markdown), so a lossy push makes *every* no-op sync look like an edit; and the lossy output would *corrupt content pushed back to Linear* (dropped checkboxes, lost code-fence languages). This is the same lossy-round-trip failure mode the spec already rejected pandoc for — it applies to =ox-md= too. + +The push converter (=pearl--org-to-md=) is therefore hand-rolled as the symmetric inverse of the fetch converter (=pearl--md-to-org=), which makes round-trips byte-stable for the supported subset. Owning both directions also keeps the conversion tier self-consistent. *Two documented lossy edges remain* (inherent to the fetch converter, not the push side): a markdown =# heading= renders to a bold line on fetch and stays a bold line on push (restoring =#= would fork the org outline); single-asterisk markdown italics are unsupported on fetch (only =_underscore_= italics convert). + +** Pandoc — pros/cons + +- *Pros:* full-fidelity bidirectional GFM↔org; one tool; battle-tested. +- *Cons:* hard external-binary dependency (MELPA-hostile; users without it get broken sync); subprocess per conversion; *lossy round-trip* (pandoc reflows/normalizes → spurious diffs on no-op fetch/push); cross-platform/version drift. + +** Recommendation — pure-elisp default, pandoc optional + +Hand-roll *both* directions: push via =pearl--org-to-md= (the inverse of the fetch pass — see [[*ox-md rejected for push][ox-md rejected for push]]), fetch via the lightweight md→org pass. No dependency, byte-stable round-trips. Pandoc is an *optional* enhancement: if =(executable-find "pandoc")= and a defcustom opts in, route both directions through it. Detected, never required — MELPA-safe. + +** Conversion matrix (the testable contract) + +"High-frequency constructs" needs a precise, testable subset. Unsupported constructs are *preserved as literal text*, never emitted as malformed org. + +| Markdown | Org | Note | +|----------+-----+------| +| =**bold**= | =*bold*= | | +| =*italic*= / =_italic_= | =/italic/= | underscores in identifiers must not trigger emphasis | +| =`code`= | =~code~= | | +| =```lang ... ```= | =#+begin_src lang ... #+end_src= | language preserved | +| =- item= / =* item= | =- item= | | +| =1. item= | =1. item= | | +| =- [ ]= / =- [x]= | =- [ ]= / =- [X]= | checkboxes | +| =[text](url)= | =[[url][text]]= | | +| => quote= | =#+begin_quote ... #+end_quote= | | +| =# Heading= | *bold line*, NOT an org heading | an org heading would fork the issue subtree and corrupt structure | +| tables / HTML / footnotes | literal pass-through | preserved, not converted | + +The =# Heading= → bold-line rule is load-bearing: converting a markdown heading inside a description to a real org heading would split the issue's subtree. + +* Conflict handling + +The round-trip-drift guard is necessary but not sufficient — it prevents no-op churn; it doesn't define conflicts. Promote it from a note to a *phase gate on sync-back*. The sync command compares three things: + +- *last-fetched* Linear markdown (hash in =:LINEAR-DESC-SHA256:=), +- *current org-rendered* markdown (re-render the body to md, hash it), +- *current remote* markdown / =updatedAt= (fetch before pushing). + +Outcomes: + +- org == last-fetched → no local edit → *no API call* (no-op guard). +- org changed, remote == last-fetched → clean push. +- org changed *and* remote changed since last fetch → *conflict*: stop, refuse to push, message the user (decision 5). Resolution workflows (diff/merge, local/remote-wins) are vNext. + +* Parsing — org-element, not regex + +Current parsing assumes a level-3 heading with a drawer immediately after and walks lines/regex. Once bodies and comment subtrees exist, that's brittle (misread drawers, nested comment headings mistaken for issues). Spec an =org-element=-based parser: locate issue headings by the durable =:LINEAR-ID:= property, read properties via org APIs, treat depth structurally — never =^\*\*\*= regexes. + +* Internal representation + +Normalize API responses into internal plists/structs *before* rendering, so the renderer never sees whether Linear returned a vector, =null=, or an omitted field. Comments, assignees, cycles, and views multiply the missing/null/vector handling otherwise. Model boundaries (filter compilation, API transport, issue/comment models, org rendering, org parsing, sync orchestration, commands) stay as *logical* sections — see [[*Review dispositions][Review dispositions]] on keeping a single file. + +* Actions a user wants in the body space + +- *Edit the description* in place → explicit sync (push, behind the conflict gate). +- *Read the comment thread* without leaving Emacs. +- *Add a comment* → =commentCreate= (append a sub-heading). +- (later) navigate sub-issues. + +Field changes (assignee/priority/labels/state) stay command-driven, not body edits. + +* Impact on existing todo.org tasks + +Gives concrete shape to three already-open feature tasks; implement them together: + +- =Sync title and description back to Linear= — description-in-body + explicit push. *Phase title sync separately* from description (its own last-fetched-title hash + conflict behavior; note the existing bracket-stripping lossiness). Keep TODO-keyword state sync as the only automatic heading mutation in the first body-editing phase. +- =Add a comment to an issue from Emacs= — the comment subtree + =commentCreate=. +- =Set assignee, priority, and labels from Emacs= — command-driven drawer fields (mutate by ID). + +Cross-cuts the query spec at the shared write path and the active-file/refresh model. + +* Phased implementation + +1. *Description → body (read-only) + namespaced properties.* Move description out of =:DESCRIPTION:= into the body; switch to =LINEAR-*= properties storing IDs + display names; provenance hash + timestamp. Characterization test of the old shape first, then the new render; confirm =org-tidy= no longer shows a blank entry. +2. *org-element parser.* Locate by =:LINEAR-ID:=, structural depth; replaces regex parsing before subtrees land. +3. *Conversion tier.* Hand-rolled org→md push (inverse of the fetch pass; =ox-md= rejected for lossy round-trips) + lightweight md→org fetch per the matrix; unit-test the matrix (Normal/Boundary/Error) and the no-op round-trip invariant. +4. *Refresh = merge by ID* + per-subtree conflict check; reconcile with the active-file replace-on-switch model. +5. *Description sync-back (explicit command)* behind the conflict gate (the round-trip guard is the phase gate). Title sync as a separate step. +6. *Comments* — add to the fetch query; render oldest-first; =add-comment= via =commentCreate=. +7. *Pandoc optional path* + the affordance line + discoverable commands. +8. *(later)* sub-issues, comment editing, local notes, save-hook automation, interactive conflict resolution. + +* Test strategy + +*Characterization (before changing rendering):* old shape renders description in =:DESCRIPTION:= with empty body; dirty visiting buffer not overwritten; state sync uses only matching issue headings; current parser behavior with drawer placement. + +*Per phase:* description after =:END:= with no =:DESCRIPTION:= property; org-element parser extracts properties even with body text + comment subtrees; comments render with IDs/timestamps oldest-first; =add-comment= makes one mutation and inserts/refreshes the returned comment; no-op description sync makes *no* API call; local-edit + remote-unchanged pushes the expected markdown; local-edit + remote-changed refuses with a conflict message; unsupported markdown stays readable and doesn't corrupt org. + +*Golden rendering:* small, intentional string snapshots of representative issue entries. + +* Open decisions + +None blocking v1 — the five agreed decisions resolved the ownership, conflict, sync-trigger, comment-immutability, and property-naming questions. Remaining calls are implementation-level (exact converter edge handling, command key bindings). + +* vNext + +- Local-only notes under issues, if a clean ownership representation emerges. +- Editing existing comments — only those authored by the current Linear user. +- Automatic description sync on save (after no-op detection + conflict handling are proven). +- Interactive conflict handling: diff/merge, local-wins, remote-wins, manual merge. +- Read-only text properties on remote-owned regions (after the command UX exists). +- Sub-issue rendering. + +* Review dispositions + +All review recommendations were accepted and incorporated above except the following, modified with reasons: + +1. *"Split representation from network/API code" into modules → modified.* Adopted the *logical* boundaries (filter compilation / transport / models / rendering / parsing / sync / commands) and the "normalize before rendering" discipline, but *kept a single file* for v1. The package is a single-file =pearl.el= aiming at MELPA, where single-file is a virtue; splitting into multiple files is a larger restructuring with its own review. Logical sections + pure helpers get the unit-testability the review wants without the file split. Revisit multi-file only if size forces it. + +2. *Read-only text properties on remote-owned regions → deferred (the review's own lighter recommendation).* v1 detects edits to remote-owned generated areas and warns/refuses to push rather than making regions buffer-read-only, which would frustrate org users and complicate tests. Hard read-only is in vNext. + +Everything else — Linear-owned body, namespaced =LINEAR-*= properties, IDs-with-display-names, hash+timestamp provenance (not raw-markdown-in-property), merge-by-ID refresh reconciled with active-file replace, conflict detect/refuse/message as a phase gate, explicit-command sync, separate title/description sync, org-element parsing, the conversion matrix, oldest-first read/add-only comments, normalized model objects, discoverable subtree commands, and the full test strategy — was accepted as written. diff --git a/docs/issue-sort-order-spec.org b/docs/issue-sort-order-spec.org new file mode 100644 index 0000000..f49e869 --- /dev/null +++ b/docs/issue-sort-order-spec.org @@ -0,0 +1,65 @@ +#+TITLE: pearl — Interactive Sort/Order Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +*DRAFT — design proposal; nothing in =pearl.el= has changed.* Open questions for Craig at the end. + +Companion to [[file:issue-query-spec.org][issue-query-spec.org]], which defines the =:sort= / =:order= the saved-query layer already supports. This doc covers changing the order of the *current* view interactively, without hand-editing a saved query. + +* Problem + +v1 supports =:sort= (=updated= / =priority= / =title=) and =:order= (=asc= / =desc=) on saved queries. But to change how the active file is ordered, the user has to edit =pearl-saved-queries= (or the source plist) by hand and re-run. There's no "sort this view by priority, descending" command. For a view you're actually looking at, that's the natural thing to want. + +* Current state + +- =pearl--sort-issues= (query spec) applies =:sort= / =:order= client-side at the render boundary, so a refresh always lays headings out the same way. +- =:sort= = =priority= / =title= are client-side; =created= / =updated= map to the server =orderBy= (the only fields Linear's API orders on). The query spec documents this split. +- The active file's =#+LINEAR-SOURCE:= header records the source plist, including any =:sort= / =:order=, so =refresh-current-view= reproduces the ordering. + +* Proposed design + +** The command + +=pearl-set-sort= (interactive), run in the active file: + +1. =completing-read= the sort key: =updated=, =created=, =priority=, =title=. +2. =completing-read= (or a toggle) the order: =asc= / =desc=. +3. Update the =:sort= / =:order= in the active file's recorded =#+LINEAR-SOURCE:=. +4. Re-order the view (see below). + +A =pearl-toggle-sort-order= convenience command just flips =asc=/=desc= on the current sort and re-orders. Both go on the transient menu (a small "Sort" group, or under View). + +** Re-order in place vs refetch + +The split matters for whether a sort change needs the network: + +- *Client-side sorts* (=priority=, =title=): the issues are already in the buffer. Re-sort in place — reparse the issue subtrees, reorder them, rewrite. No refetch. Fast, works offline. +- *Server-side sorts* (=created=, =updated=): the ordering comes from the server =orderBy=, and the fetch may have been truncated at the page cap, so the correct order needs a refetch with the new =orderBy=. Re-run the source (the =refresh-current-view= path) with the updated sort. + +So =set-sort= decides: client-side key → re-sort the buffer; server-side key → refetch. The command reports which it did. + +** Persistence + +The change updates the active file's =#+LINEAR-SOURCE:= so a later =refresh-current-view= keeps the new order. Whether it also writes back to a named saved query in =pearl-saved-queries= is open question 3 — my lean is no by default (the active file is the scratch view; saved queries are the durable definitions), with an explicit "save this ordering to the query" as a separate step. + +* Proposed v1 decisions (this feature) + +1. =pearl-set-sort= + =pearl-toggle-sort-order=, both on the transient menu. +2. Client-side keys re-sort the buffer in place; server-side keys refetch. +3. The change updates the active file's recorded source so refresh preserves it. +4. Completion is over the known keys/orders, never free text. + +* vNext / out of scope + +- Multi-key sort (e.g. priority then updated). +- Per-heading manual reordering that sticks across refresh. +- Exposing the full Linear =orderBy= surface if the API later un-gates the =[INTERNAL]= per-field sort. + +* Open questions for Craig + +1. *Command vs transient-only*: a plain =M-x pearl-set-sort= with two completing-reads, or a dedicated transient sub-menu with one-key sort toggles (=p= priority, =u= updated, =t= title, =o= flip order)? The transient reads faster for a frequent action. +2. *In-place re-sort fidelity*: re-sorting client-side means reparsing and rewriting issue subtrees in the buffer. Acceptable, or prefer always-refetch for simplicity even when a client-side sort wouldn't need it? +3. *Write-back*: should changing the sort offer to persist it to the originating saved query, or only ever update the active file's header? diff --git a/docs/labels-as-org-tags-spec.org b/docs/labels-as-org-tags-spec.org new file mode 100644 index 0000000..a1bb413 --- /dev/null +++ b/docs/labels-as-org-tags-spec.org @@ -0,0 +1,81 @@ +#+TITLE: pearl — Render Linear Labels as Org Tags Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +PROPOSED — awaiting review. Implements the =todo.org= task "Org tags should reflect the issue's Linear labels" (filed 2026-05-24 after a hardcoded personal =#+filetags:= value was removed from the header writer in 952cfe7). + +* Problem + +Pearl used to stamp a hardcoded personal =#+filetags:= value on every fetched file — file-wide, unrelated to any issue. That's gone. What's missing is the useful behavior: an issue's Linear labels should appear as org tags *on that issue's heading*, so the org-native gestures work — filter by tag, build a tag agenda, sparse-tree on =:bug:=. Today labels live only in the =:LINEAR-LABELS:= drawer (=[bug, backend]=), which org's tag machinery can't see. + +* Current state + +- =pearl--format-issue-as-org-entry= renders the heading as =** <STATE> [#P] <title>= with no tags, and writes the labels into the =:LINEAR-LABELS:= drawer as =[name, name]=. +- =pearl--normalize-issue= gives each issue =:labels= as a list of plists with =:name=. +- =pearl--issue-title-at-point= already extracts the title with =(org-get-heading t t t t)= — the four flags strip the keyword, priority cookie, *tags*, and comment markers. So title sync is already tag-aware; adding heading tags will not corrupt the title round-trip. +- =pearl-set-labels= changes an issue's labels (completing-read over team labels), pushes, and rewrites the =:LINEAR-LABELS:= drawer. + +* Proposed design + +** Tag slugify: label name → org tag + +A new pure helper =pearl--label-name-to-tag=. Org tags are restricted to =[[:alnum:]_@#%]= — notably *no hyphens or spaces* (unlike TODO keywords, which allow hyphens). So this slugify differs from the keyword one: + +- Downcase (tags are case-sensitive in Org; lowercase is the convention). +- Replace each run of characters outside =[[:alnum:]_]= with a single =_=. +- Trim leading/trailing =_=. + +Examples: =Bug= → =bug=, =Needs Review= → =needs_review=, =P1= → =p1=, =backend/api= → =backend_api=, =UI/UX= → =ui_ux=. A label that slugifies to empty is dropped. + +(Contrast with =pearl--state-name-to-keyword= from the workflow-states spec, which upcases and uses hyphens. Two different targets, two different slugify rules — keep them as separate, clearly-named helpers.) + +** Render tags on the issue heading + +=pearl--format-issue-as-org-entry= appends the issue's label tags to the heading line in org tag syntax: + +: ** TODO [#B] Fix the thing :bug:backend: + +Slugify each label name, de-duplicate (preserving order), and join as =:t1:t2:=. No labels → no tag string. The =:LINEAR-LABELS:= drawer stays as the canonical structured store (it holds the exact Linear names, which the tag form lossily slugifies); the heading tags are the derived, org-native view. + +Only *issue* headings get tags. The parent view heading and the Comments / individual-comment headings carry none. + +** Keep the drawer authoritative; tags are a view + +The =:LINEAR-LABELS:= drawer remains the source of truth for the issue's labels (it survives slugify collisions and preserves the display names). =pearl-set-labels= continues to push and rewrite the drawer, and additionally re-renders the heading's tag string so the two stay consistent after a label change. + +** v1 is render-only (fetch direction) + +Editing the heading's tags by hand does *not* push to Linear in v1. The way to change labels stays =pearl-set-labels= (which now updates both the drawer and the heading tags). Bidirectional sync — parse the heading's tags on save and reconcile them against Linear's label set — is deferred (see out of scope); it needs the same kind of conflict gate the description/title/comment syncs have, and label *creation* semantics (a tag with no matching Linear label) to be decided. + +* Files touched + +- =pearl.el=: new =pearl--label-name-to-tag=; =pearl--format-issue-as-org-entry= (append tag string to the heading); =pearl-set-labels= (re-render heading tags after a label change). The =:LINEAR-LABELS:= drawer line is unchanged. +- Tests: new cases for =pearl--label-name-to-tag= (normal, multiword, punctuation, collision, empty); a render test (issue with labels → heading carries the slugified tags); a regression test that =pearl--issue-title-at-point= / title sync still returns the bare title with tags present; a =pearl-set-labels= test that the heading tag string updates. +- =README.org=: note in the Fields / "active org file" section that labels render as heading tags; mention the drawer remains the structured store. + +* Test plan + +- *Tag slugify*: =Bug=→=bug=, =Needs Review=→=needs_review=, =UI/UX=→=ui_ux=, =P1=→=p1=, collision de-dup, empty-after-slug dropped. +- *Render*: an issue with labels =("Bug" "Backend")= renders =** … :bug:backend:= and still carries =:LINEAR-LABELS: [Bug, Backend]= in the drawer. +- *Title-sync regression*: with tags on the heading, =pearl--issue-title-at-point= returns the bare title (no tags, no keyword), so a no-op title sync still matches and a title edit still round-trips. +- *set-labels*: after changing labels, the heading tag string reflects the new set (and the drawer too). + +* Migration + +Additive — no breaking change. Existing files gain heading tags on the next fetch. The removed =#+filetags= is already gone (952cfe7). No defcustom changes. + +* Open questions for review + +1. *Tag case.* Lowercase (=Bug= → =bug=) for org-tag convention, or preserve Linear's case (=Bug= → =Bug=)? Lowercase is my lean; it matches how most org users tag and avoids =Bug= vs =bug= duplication. +2. *=#+TAGS:= declaration.* Should the file declare =#+TAGS:= with the union of label slugs (for tag-completion in the buffer), the way the workflow-states spec derives =#+TODO:=? Bonus, not required for the feature. My lean: defer to a follow-up to keep v1 focused. +3. *Tag inheritance.* Org tag inheritance is on by default, so an issue's tags apply to its Comments subtree in agenda/inheritance contexts. Harmless (a comment "inheriting" =:bug:= rarely matters), but flagging. Disable inheritance for these files, or accept it? My lean: accept it. +4. *Bidirectional sync* (edit heading tags → push labels) — confirm it's out of scope for v1. + +* vNext / out of scope + +- Bidirectional tag editing (heading tags → Linear labels) with a conflict gate and label-creation semantics. +- =#+TAGS:= completion declaration. +- Color/face mapping from Linear label colors to org tag faces. diff --git a/docs/todo-keywords-from-workflow-states-spec.org b/docs/todo-keywords-from-workflow-states-spec.org new file mode 100644 index 0000000..fc0a51c --- /dev/null +++ b/docs/todo-keywords-from-workflow-states-spec.org @@ -0,0 +1,209 @@ +#+TITLE: pearl — Derive Org TODO Keywords from Linear Workflow States Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-05-24 +#+STARTUP: showall + +* Status + +READY — implementation-ready, awaiting Craig's final go. Reviews incorporated (2026-05-24, rounds 1–4); round 4 returned a Ready verdict with no blocking findings. Implements the =todo.org= task "Derive the org TODO keywords from the Linear workflow states". One implementation prerequisite remains (verify =WorkflowState.position=; see prerequisites). + +* Problem + +The generated file's =#+TODO:= line is fixed — either the hardcoded =TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE= or a copy of the user's global =org-todo-keywords= — and =pearl-state-to-todo-mapping= is a static six-entry default. Neither reflects a team's real Linear states (Dev Review, PM Acceptance, Icebox, Grooming, …). + +Org only cycles a heading to a keyword listed in the file's =#+TODO:=. So you cannot move a ticket to "Dev Review" by cycling its TODO keyword: the keyword isn't in the line, and there's no mapping entry for it. =pearl-set-state= already reaches any state (id-based, header-independent), but the keyword-cycle path — the natural org gesture — is stuck on the hardcoded six. + +The =#+TODO:= line therefore becomes *generated infrastructure* for the sync-back write path. If it is missing a rendered keyword, stale after a merge refresh, or ambiguous after slugification, org-native state changes silently stop being a trustworthy write path. The header-and-reverse-lookup contract below is written to that bar. + +* Current state + +- =pearl--build-org-content= writes =#+TODO:= from =org-todo-keywords= (or the hardcoded fallback). It's a pure function. +- =pearl--map-linear-state-to-org= renders an issue's keyword by =assoc= on =pearl-state-to-todo-mapping= (fallback =TODO=). +- =pearl--map-org-state-to-linear= resolves a cycled keyword back to a Linear state *name* by =rassoc= on the same mapping; =pearl--process-heading-at-point= then calls =pearl--update-issue-state-async= with that name + team id. +- =pearl--get-todo-states-pattern= builds the full-file scan regex from the mapping's keywords (cached in =pearl-todo-states-pattern=). +- =pearl--team-states= is a *synchronous, cached* accessor (blocks via =pearl--wait-for= on first fetch, then serves from =pearl--cache-states=). =pearl-set-state= already calls it synchronously from command context. +- The team-states GraphQL query fetches =id name color= only. Issue queries already fetch state =type= (but not workflow-state =position=). +- The same-source refresh path (=pearl--merge-query-result= → =pearl--merge-issues-into-buffer=) updates issue subtrees in place and only rewrites the run-at / count / truncation header lines via =pearl--update-source-header=. It does *not* rebuild the file, so it does not touch =#+TODO:= today. + +* Decisions + +Settled inputs for v1 (A1 / B2 / C-yes agreed with Craig 2026-05-24; the remainder resolved from review): + +- *A1 — union.* A file may hold issues from several teams. Build one =#+TODO:= line from the union of all involved teams' states, de-duplicated by slug. +- *B2 — derived replaces.* The keyword is always the slugified Linear state name. =pearl-state-to-todo-mapping= is *removed*, not layered. One source of truth (Linear), an honest header, a deterministic round-trip (keyword = =slugify(name)= everywhere, no stored reverse map). +- *C-yes — defer cross-team slug collisions.* Documented known limitation (see out of scope). +- *Done-side types.* =completed=, =canceled=, *and* =duplicate= render after the =|=; =triage=, =backlog=, =unstarted=, =started= before it. Split by =type=, never by name. +- *Header coverage guarantee.* Every keyword visible on a heading in the buffer must be declared in =#+TODO:= — that header powers org cycling and sync-back. The header is the slug-union of (a) every visible issue heading's state and (b) every team's full state set that was fetched successfully. (a) guarantees coverage even when a team's state fetch fails; (b) makes absent states cyclable when available. A failed team's only degradation is that you can't cycle to a state none of its visible headings is in. The hardcoded line is used only when there are no states at all. The "visible headings" set differs by path: a full rebuild reads the normalized issue list; a merge refresh reads the *final buffer* (so retained/skipped dirty subtrees are covered — see below). +- *Merge coverage via final-buffer scan.* For merge refresh the header is rebuilt from the final displayed buffer (scan every Linear issue heading's current TODO keyword) unioned with the fetched team states — not from the fetched issue list alone, which omits retained dirty subtrees. The buffer scan subsumes the fetched issues (they're in the buffer) and directly validates the invariant users see. +- *Issue-own / position-less ordering.* Full workflow states (from team-states) order by =position=; states drawn from headings or the issue list carry no workflow =position=, so they append in first-seen order within their active/done partition, de-duped by slug. +- *Slugify is Unicode-aware and locale-independent.* +- *Same-team slug collision.* The pure gather/derive layer returns collision metadata (the colliding slug + states); the render/sync layer logs it. The header de-dups; sync-back resolves the keyword to the *first state by =position=*. Returning the metadata keeps the lossy transform testable without capturing =message=. +- *Unknown keyword behavior splits by path.* Interactive current-heading sync (an =org-todo= cycle) whose keyword resolves to no team state raises a =user-error= naming the keyword + team. The full-file save scan reports and *skips* the unknown heading and continues to the rest — one stale heading must not abort syncing the others or surface as an after-save-hook error. +- *Store the state type in the drawer.* Render a =:LINEAR-STATE-TYPE:= drawer field (the issue query already fetches state =type=). The active/done side of a keyword is a function of =type=, and the merge final-buffer scan recovers a retained heading's keyword but not its type from name/id alone — so the type must travel with the heading. Classification on the merge scan: by the heading's own =:LINEAR-STATE-TYPE:= when present (deterministic); else (a legacy heading written before this field) preserve the keyword's current side from the buffer's parsed TODO config — done side if the keyword is in =org-done-keywords=, active otherwise; else (no parseable =#+TODO:= / keyword unknown to Org) default to the active side and log a warning naming the keyword and issue. Retained headings thus keep their old Org done/active semantics until a clean refresh re-derives them from Linear. + +* Proposed design + +** Slugify: state name → org keyword + +A new pure helper =pearl--state-name-to-keyword=: + +- Upcase (locale-independent — Emacs =upcase=, no locale-sensitive casing). +- Replace each run of characters *not* matched by =[[:alnum:]]= with a single hyphen. =[[:alnum:]]= is Unicode-aware in Emacs, so accented and non-Latin letters are preserved rather than stripped. +- Trim leading/trailing hyphens. +- If the result is empty (an all-punctuation/symbol name), fall back to =TODO=. + +Expected outputs: + +| Input | Output | +|--------------------+-------------------| +| =Dev Review= | =DEV-REVIEW= | +| =In Progress= | =IN-PROGRESS= | +| =Todo= | =TODO= | +| =PM Acceptance= | =PM-ACCEPTANCE= | +| =Backlog (prioritized)= | =BACKLOG-PRIORITIZED= | +| =Dev-Review= | =DEV-REVIEW= | +| =Ångström= | =ÅNGSTRÖM= | +| =!!!= | =TODO= (empty fallback) | + +Note =Dev Review= and =Dev-Review= both produce =DEV-REVIEW= — a same-team collision (handled below). The existing default mapping was effectively slugify already (=Todo=→=TODO=, =In Progress=→=IN-PROGRESS=, …), so slugify reproduces today's keywords for those states. + +** Same-team slug collisions + +When two states in one team slugify to the same keyword (=Dev Review= and =Dev-Review=), the =#+TODO:= line lists the keyword once (de-dup, first-seen by =position= wins its slot). Sync-back resolves that keyword to the *first state by =position=* and logs a one-line warning naming the colliding states, so the behavior is deterministic and visible. Cross-team collisions are deferred (out of scope) — sync still resolves correctly per the heading's own team, but the header can't distinguish them. + +** Derive the =#+TODO:= line + +A new pure helper =pearl--derive-todo-line= takes an ordered list of states (each =(:name :type :position)=) and returns the keyword string: + +- Partition by =type=: done-side = =completed=/=canceled=/=duplicate=; active-side = everything else. +- Within each side, preserve the input order (the caller supplies states already ordered — see Multi-team ordering). +- Slugify each name; de-duplicate by slug, preserving first-seen order. +- Result: ="ACTIVE-1 ACTIVE-2 … | DONE-1 DONE-2 …"=. + +When the state list is empty, return the hardcoded =TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE= so the file is always valid. + +** Multi-team ordering + +=position= is meaningful within a team, not across teams. To keep the header deterministic: + +1. Teams are ordered by *first-seen order in the sorted issue list* (the issues are already sorted before render). +2. Within each team, states are ordered by Linear =position=. +3. The union concatenates teams in that order, then de-dups by slug (first-seen wins), then the derive-line partitions by type. + +So the header order is stable across runs regardless of hash/traversal order. + +** Gather the states (pipeline) + +Because =pearl--team-states= is synchronous-with-cache and =pearl-set-state= already calls it from command context, the render path gathers states synchronously without restructuring into async callbacks: + +1. After issues are normalized and sorted, collect the distinct =LINEAR-TEAM-ID= values in first-seen order. +2. For each team, call =pearl--team-states= (cached after the first hit). Show a progress message while a fetch blocks; on a team's fetch failure, log it and continue (per the coverage guarantee). +3. Build the union: every displayed issue's own state, plus the full states of each team that fetched. Order per Multi-team ordering, de-dup by slug. +4. Hand the union to =pearl--build-org-content=. + +=pearl--build-org-content= stays pure: it gains a =states= argument (the ordered union list) and writes the derived =#+TODO:= via =pearl--derive-todo-line=. The async layer does the synchronous gather just before calling it. One synchronous, cached team-states fetch per distinct team per session — usually one or two; multi-team views do N bounded blocking fetches. + +The team-states query gains =type= and =position= (currently =id name color=), and the cache entry keeps them. + +** Render each issue's keyword + +=pearl--format-issue-as-org-entry= renders the heading keyword as =pearl--state-name-to-keyword(issue-state-name)= instead of =pearl--map-linear-state-to-org=. Safe because the header always includes each displayed issue's own state (coverage guarantee). It also writes a =:LINEAR-STATE-TYPE:= drawer field next to =:LINEAR-STATE-ID:= / =:LINEAR-STATE-NAME:=, so a later merge scan can classify a retained heading onto the correct side of the =|= from the heading itself (see the classification decision). + +** Generated header update on refresh + +The same-source merge refresh must keep the header honest: a refresh can add an issue from a team whose state keyword isn't yet declared, or surface a renamed/added/removed state — *and* it retains existing subtrees that the merge skips. =pearl--merge-issues-into-buffer= keeps a dirty existing subtree (unpushed body edits) without re-rendering it, and keeps a dirty issue that's absent from the refreshed result rather than dropping it. Those headings stay visible after the refresh, so their keywords must be declared too. + +A new helper =pearl--update-derived-todo-header= rewrites the =#+TODO:= line in place (creating it if absent). It derives from the *final displayed buffer*: scan every Linear issue heading (one carrying =LINEAR-ID=) for its current TODO keyword *and its =:LINEAR-STATE-TYPE:= drawer*, union those with the fetched team states (per the ordering rules), classify each onto the active/done side (by type when known; otherwise the fallback in the classification decision), and rewrite the line. =pearl--merge-query-result= calls it after the merge and the state gather, alongside =pearl--update-source-header=. Scanning the final buffer — rather than building from the fetched issue list — is what guarantees retained/skipped subtrees are covered, and it directly validates the invariant: every keyword visible in the buffer is declared on the correct side of the bar. + +** Sync-back: cycled keyword → Linear state + +=pearl--process-heading-at-point= resolves the cycled keyword via the heading's team rather than the removed mapping: + +1. Read the heading's TODO keyword + =LINEAR-TEAM-ID=. +2. =pearl--team-states= (cached) for that team; find the state whose =slugify(name)= equals the keyword (first by =position= on a collision). +3. If a state matches, push it via =pearl--update-issue-state-async= (unchanged; it resolves name → id per team). +4. If *no* state matches (stale buffer keyword after a workflow change, or an old mapped keyword from a pre-upgrade file), the behavior depends on the path. Interactive current-heading sync (an =org-todo= cycle) raises a =user-error= naming the keyword + team and suggesting a refresh or =pearl-clear-cache=. The full-file save scan (=pearl-sync-org-to-linear=, non-=org-todo= path) reports the unknown heading (a =pearl--log= / message) and *skips* it, continuing to the rest — one stale heading must not abort the scan or fail an after-save hook. Neither path ever silently no-ops or pushes a wrong state. + +No persisted reverse map: the keyword is always =slugify(name)=, so the match recomputes from the team's live states. + +** The full-file scan pattern + +=pearl--get-todo-states-pattern= no longer builds from the mapping. The full-file sync scan (=pearl-sync-org-to-linear=, non-=org-todo= path) builds its keyword alternation from the buffer's own =org-todo-keywords-1= (what Org parsed from =#+TODO:=), so it matches whatever the derived header declared, with no stale cache. The =pearl-todo-states-pattern= / =pearl--todo-states-pattern-source= caches are removed with the mapping. + +** User-facing errors + +None of these fail silently: an unknown heading keyword on sync-back (=user-error= naming keyword + team), a missing =LINEAR-TEAM-ID= on a heading being synced, a team state fetch that fails during render (logged + progress/skip message), and a same-team slug collision (logged warning). Each names the offending value. + +* Implementation prerequisites + +- *Verify =WorkflowState.position=* against the current Linear schema or a live query before implementation. Issue queries fetch state =type= today but not workflow-state =position=. If =position= is unavailable, fall back to the order the API returns states in (still deterministic per fetch) and note it. +- Confirm the Linear state =type= enum is =triage/backlog/unstarted/started/completed/canceled/duplicate= (already verified in =docs/issue-query-spec.org=). +- Only =pearl--team-states= gains the =type=/=position= fields. =pearl-get-states-async= / =pearl-get-states= (used by creation flows) fetch =id name color= and are intentionally left unchanged — this feature doesn't depend on them. The two query shapes diverging is acceptable for v1; aligning them is optional follow-up cleanup. + +* Phased implementation plan + +Ordered so dependencies land first. + +1. *Pure core.* =pearl--state-name-to-keyword= and =pearl--derive-todo-line= + their tests (no I/O). Everything else depends on these. +2. *Query + gather.* Add =type=/=position= to the team-states query (after the position verification); add the synchronous gather helper (distinct teams → union with coverage guarantee + multi-team ordering). +3. *Full rebuild.* =pearl--build-org-content= takes =states=, writes the derived header; =pearl--format-issue-as-org-entry= renders via slugify and adds the =:LINEAR-STATE-TYPE:= drawer field. Assert the rebuilt header declares every rendered heading keyword. +4. *Merge refresh.* =pearl--update-derived-todo-header= (final-buffer scan + active/done classification by the heading's =:LINEAR-STATE-TYPE:=, with the org-done-keywords fallback) wired into =pearl--merge-query-result=. +5. *Sync-back.* Team-aware resolve in =pearl--process-heading-at-point= with unknown-keyword refusal; scan pattern from =org-todo-keywords-1=. +6. *Remove the mapping.* Delete =pearl-state-to-todo-mapping=, =pearl--map-linear-state-to-org=, =pearl--map-org-state-to-linear=, =pearl-todo-states-pattern=, =pearl--todo-states-pattern-source=. Replace =test-pearl-mapping.el= with =test-pearl-keywords.el= (keep the regression class: a changed header/keyword set affects full-file scan with no stale cache). +7. *Docs.* README state-mapping section + customization table; migration notes. + +* Test plan + +- =pearl--state-name-to-keyword=: ASCII names, punctuation, repeated punctuation, high-ASCII/accented, double-byte, combining characters, emoji/symbol-only → =TODO=, empty string, =Dev Review= vs =Dev-Review= collision, a real =Todo= alongside an empty-derived =TODO=. +- =pearl--derive-todo-line=: active/done partition *including =duplicate= after the bar*, per-team =position= ordering, deterministic multi-team order, duplicate-slug first-wins, empty-states fallback. +- *Full rebuild*: =build-org-content= with a state set emits the derived =#+TODO=; the header declares every rendered heading keyword; an issue in =Dev Review= renders the keyword =DEV-REVIEW= on its level-2 heading. +- *Merge refresh*: a same-source refresh where a newly fetched issue introduces =DEV-REVIEW= updates the buffer's =#+TODO:= line (creates it if missing). +- *Merge refresh — retained dirty subtree (absent from result)*: an old dirty issue in =QA-REVIEW= is gone from the refreshed result, is kept by the merge, and the rewritten =#+TODO:= still declares =QA-REVIEW=. +- *Merge refresh — skipped dirty subtree (still in result)*: a dirty issue still present is skipped (not re-rendered), and the rewritten header still declares its current kept keyword even if it differs from the fetched issue's new state. +- *Merge refresh — done-side classification of a type-less retained heading*: a retained dirty heading whose keyword was on the done side, whose team-state fetch fails and which lacks =:LINEAR-STATE-TYPE:= (legacy), keeps its keyword *after* the =|= via the org-done-keywords fallback. Same setup for an active retained heading keeps it *before* the bar. A missing/unparseable old header defaults the unknown keyword to active and logs the ambiguity. +- *Drawer carries the type*: a freshly rendered issue's drawer includes =:LINEAR-STATE-TYPE:=, and a merge scan classifies it by that field without the fallback. +- *Partial failure*: a multi-team result where one team's state fetch fails still declares every rendered keyword (issue-own states in the header) and renders without error; the two issue-own fallback states from the failed team order deterministically (first-seen within partition). +- *Sync-back*: =DEV-REVIEW= resolves through the heading's team states; a same-team collision resolves to first-by-position. +- *Unknown keyword by path*: interactive current-heading sync of an unknown keyword raises =user-error=; the full-file scan reports and skips it and still syncs the other headings. +- *Collision metadata*: the pure gather/derive layer returns the colliding slug + states (asserted directly, no =message= capture). +- *Migration/regression*: an old mapped keyword no longer in the derived header does not silently push a wrong state (interactive refuses; scan skips). +- *Scan pattern*: the full-file scan matches a derived keyword present in the buffer's =#+TODO= with no stale-cache dependency. + +* Migration / breaking change + +Removing =pearl-state-to-todo-mapping= is a breaking change for anyone who set it. The package is pre-release (MELPA pending), so no deprecation cycle. The commit is =feat!:= with a =BREAKING CHANGE:= footer. + +*Upgrade path:* after upgrading, *refresh a Pearl file before cycling TODO keywords on it.* An old file's header and headings may use custom-mapped keywords that no longer resolve; cycling one of those now *refuses* (unknown-keyword =user-error=) rather than silently pushing a wrong state, so the safe move is to re-fetch the file so its header and keywords become the derived set. The README migration note must state this because the defcustom is going away. + +* Review dispositions + +Only modified or rejected recommendations, and decisions worth recording, are listed; everything else from the reviews (2026-05-24, rounds 1–4) was accepted as written and woven into the body above. + +** Round 4 (2026-05-24) + +Ready verdict — no blocking findings. The round-3 classification blocker is confirmed resolved (=:LINEAR-STATE-TYPE:= drawer + legacy fallback). The sole caveat, verifying =WorkflowState.position=, was already recorded as an implementation prerequisite. Tidied the one org-lint nit (a literal double-star =DEV-REVIEW= example in the test plan) the reviewer flagged as harmless. + +** Round 3 (2026-05-24) + +- *HP1 "active/done classification of type-less scanned headings" — modified.* The review's fallback (preserve the keyword's side from the buffer's parsed =org-done-keywords=) is a recovery run on every merge. Modified to stop losing the type at the source: render a =:LINEAR-STATE-TYPE:= drawer field (free — the issue query already fetches =type=) so a scanned heading classifies by its own recorded type deterministically. The review's org-done-keywords-side preservation is kept as the *fallback* for legacy headings lacking the field, with default-active-and-log as the last resort. Fully addresses the concern and removes the per-merge reparse for go-forward files. +- *Open question 1 (preserve old side vs default-all-active) — resolved:* preserve the old side, primarily via the stored type, with the parsed-header fallback — not default-all-to-active. +- MP1 (two state-fetch API shapes) accepted: extend =pearl--team-states= only; leave =pearl-get-states-async= unchanged for v1. + +** Round 2 (2026-05-24) + +- *HP1 "retained dirty subtrees in the header" — accepted, option (b).* The review offered two implementations: thread retained/skipped state metadata out of =pearl--merge-issues-into-buffer=, or scan the final buffer. Chose the final-buffer scan — it directly validates the invariant ("every keyword visible is declared"), subsumes the fetched issues, and avoids widening the merge helper's return contract. +- *Open question 1 (scan: abort vs skip-and-continue) — resolved:* skip-and-continue with a report on the full-file scan; =user-error= only on interactive current-heading sync. +- *Open question 2 (final-buffer scan vs returned metadata for merge coverage) — resolved:* final-buffer scan (the HP1 option-(b) choice). +- MP1 (position-less issue-own ordering), MP2 (split unknown-keyword behavior), and MP3 (return collision metadata) accepted as written. + +** Round 1 (2026-05-24) + +- *HP3 "partial team-state fetch failure" — modified.* The review offered two rules: global hardcoded fallback on any failure, or fail the render and leave the file unchanged. Both discard information — the first drops real derived keywords for teams that succeeded; the second leaves the user with nothing. Adopted instead the *header coverage guarantee*: derive the header from the union of each displayed issue's own state (always available from the issue query) plus each successfully-fetched team's full states. This keeps every rendered keyword declared regardless of fetch outcome, and a failed team degrades only to "can't cycle to its absent states." The hardcoded line is reserved for the no-states-at-all case. The review's underlying safety requirement — "the render rule is only safe when the header contains that slug" — is met more completely this way. +- *Review open question 1 (leave-unchanged vs conservative fallback) — resolved* by the HP3 modify above: neither; the coverage-guarantee union. +- *Review open questions 2 and 3 — resolved as decisions:* slugify is Unicode-aware and locale-independent (Q2); same-team collisions resolve to first-by-position with a logged warning (Q3). Both now live in Decisions. + +* vNext / out of scope + +- *Cross-team slug collisions.* Two teams whose states slugify to the same keyword collapse to one keyword in a multi-team file; sync still resolves per the heading's own team, so the push is correct, but the header can't distinguish them. Disambiguation (team-prefixed keywords, per-team =#+TODO= sections) is deferred. +- *Automatic workflow-state cache staleness.* States are cached for the session; a mid-session Linear workflow change needs =pearl-clear-cache=. A TTL/auto-invalidation is deferred. +- *Label-color → tag-face mapping* and other presentation polish. |
