1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
|
#+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.
|