aboutsummaryrefslogtreecommitdiff
path: root/README.org
blob: 633baf43ba4fb879f03e37e234d9eb1915dabf1d (plain)
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
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
#+TITLE: pearl — Linear.app for Emacs
#+OPTIONS: toc:2 num:nil

[[https://www.gnu.org/licenses/gpl-3.0][https://img.shields.io/badge/License-GPLv3-blue.svg]]

=pearl= is an integration between Emacs and [[https://linear.app][Linear.app]]: fetch, read, edit, and create Linear issues as org-mode entries without leaving Emacs. Issues render as an org outline; their descriptions and comments live in the entry body, structured fields live in a property drawer, and dedicated commands push changes back to Linear.

* Features

- *Fetch what you want.* List your open issues, narrow by project, build an ad-hoc filter interactively, run a Linear Custom View, or run a named local saved query.
- *Issues as readable org.* Each issue is a heading; its description renders in the body (markdown converted to org), its comments render as a chronological sub-thread, and its structured fields (state, priority, assignee, labels, team, project) live in a namespaced =LINEAR-*= drawer.
- *Edit and push back.* Edit a description in the body and sync it; change the title; set priority, state, assignee, or labels by command; add a comment, or edit one of your own. Each push is explicit and confirmed against the remote.
- *Conflict-aware sync.* Pushing a description or title compares the local edit, the last-fetched baseline, and the current remote — a no-op sends nothing, a clean edit pushes, and a both-sides-changed case is refused and reported rather than clobbering.
- *Self-describing active file.* The generated file records the source it came from, so one command refreshes it; refresh a single issue at point, or the whole view.
- *Org TODO state sync.* Changing an issue's TODO keyword in org pushes the matching Linear workflow state.
- *One menu for everything.* =M-x pearl-menu= opens a transient dispatcher (magit-style) with every command grouped and a key away.

* Installation

** Prerequisites

A Linear API key (Settings → Account → API → Personal API Keys) and the =request=, =dash=, =s=, and =transient= packages (=transient= ships with Emacs 28+).

#+begin_src elisp
(require 'package)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
(package-initialize)
#+end_src

** MELPA (coming soon)

#+begin_src
M-x package-install RET pearl RET
#+end_src

** Manual

#+begin_src shell
git clone https://github.com/cjennings/pearl.git
#+end_src

#+begin_src elisp
(add-to-list 'load-path "/path/to/pearl")
(require 'pearl)
;; dependencies, if not already present:
;; M-x package-install RET request RET
;; M-x package-install RET dash RET
;; M-x package-install RET s RET
#+end_src

** Doom Emacs

In =packages.el=:

#+begin_src elisp
(package! pearl
  :recipe (:host github :repo "cjennings/pearl" :files ("*.el")))
#+end_src

In =config.el=:

#+begin_src elisp
(use-package! pearl
  :commands (pearl-list-issues pearl-new-issue
             pearl-run-view pearl-enable-org-sync)
  :init
  (setq pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory))
  :config
  (pearl-load-api-key-from-env))
#+end_src

* Configuration

** The API key

Three ways, least to most secure:

#+begin_src elisp
;; 1. Direct (not recommended -- ends up in your config).
(setq pearl-api-key "lin_api_...")

;; 2. Environment variable LINEAR_API_KEY.
(pearl-load-api-key-from-env)
#+end_src

3. =auth-source= (recommended). Add to =~/.authinfo.gpg=:

#+begin_src
machine api.linear.app login apikey password YOUR_API_KEY
#+end_src

then load it:

#+begin_src elisp
(setq pearl-api-key
      (auth-source-pick-first-password :host "api.linear.app"))
#+end_src

The host (=api.linear.app=) and login (=apikey=) must match the entry. macOS Keychain users can instead run =security add-generic-password -a apikey -s api.linear.app -w YOUR_API_KEY=.

** Output file

Issues are written to one active file, =pearl-org-file-path= (default =gtd/linear.org= under =org-directory=):

#+begin_src elisp
(setq pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory))
#+end_src

Running a different query or view *replaces* this file's contents (behind a dirty-buffer guard); one issue appears in one place.

** Default team

#+begin_src elisp
(setq pearl-default-team-id "your-team-id")  ; skips the team prompt on create
#+end_src

* The command menu

=M-x pearl-menu= opens a transient dispatcher with every command grouped by what it does: fetch, view, the issue at point, create and org-sync, and setup. It's the fastest way to reach a command without remembering its name. Bind it to a key if you use it often:

#+begin_src elisp
(with-eval-after-load 'pearl
  (global-set-key (kbd "C-c L") #'pearl-menu))
#+end_src

Every command below is also available directly via =M-x=.

* The active org file

A fetched file carries a self-describing header and one heading per issue:

#+begin_src org
#+title: Linear — My open issues
#+STARTUP: show3levels
#+TODO: TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE
#+LINEAR-SOURCE: (:type filter :name "My open issues" :filter (:assignee :me :open t))
#+LINEAR-RUN-AT: 2026-05-23 19:30
#+LINEAR-FILTER: assignee: me, open
#+LINEAR-COUNT: 12
#+LINEAR-TRUNCATED: no
#
# Body = the issue description; edit it, then M-x pearl-sync-current-issue to push.
# Comments subtree = the thread; add with M-x pearl-add-comment.
# Drawer fields change via M-x pearl-set-priority / -state / -assignee / -labels.
# Refresh with M-x pearl-refresh-current-view (whole file) or -current-issue (one).

* My open issues
** TODO [#B] ENG-123 Issue title
:PROPERTIES:
:LINEAR-ID:               <uuid>
:LINEAR-IDENTIFIER:       ENG-123
:LINEAR-URL:              https://linear.app/.../ENG-123
:LINEAR-TEAM-ID:          <id>
:LINEAR-TEAM-NAME:        Engineering
:LINEAR-PROJECT-NAME:     Platform
:LINEAR-STATE-NAME:       In Progress
:LINEAR-ASSIGNEE-NAME:    Craig
:LINEAR-LABELS:           [bug, p1]
:LINEAR-DESC-SHA256:      <hash of the last-fetched description markdown>
:LINEAR-DESC-ORG-SHA256:  <hash of the rendered Org body>
:LINEAR-DESC-UPDATED-AT:  <remote timestamp>
:LINEAR-TITLE-SHA256:     <hash of the rendered title>
:END:

The issue description renders here as org, edited in place.

*** Comments
**** Author Name — 2026-05-23T10:00:00.000Z
A comment, oldest first.
#+end_src

Every issue nests under one top-level heading named after the view (here =My open issues=), so you can sort the whole set with =C-c ^= on that parent. The =#+LINEAR-SOURCE:= line records what produced the file so =refresh-current-view= can re-run it. The =LINEAR-*= drawer stores ids and display names separately; commands show names and mutate by id, so there is no per-render network lookup. The =SHA256= properties are provenance for the sync conflict gate.

* Fetching issues

| Command | What it fetches |
|---------+-----------------|
| =pearl-list-issues= | your open issues |
| =pearl-list-issues-by-project= | open issues in a chosen project |
| =pearl-list-issues-filtered= | an ad-hoc filter built interactively |
| =pearl-run-view= | a Linear Custom View, run server-side |
| =pearl-run-saved-query= | a named local saved query |
| =pearl-refresh-current-view= | re-runs the file's recorded source |
| =pearl-refresh-current-issue= | re-fetches the issue at point |

=list-issues-filtered= picks a team (which scopes the rest), then completes the state, project, and labels from that team's actual values rather than free text, so a typo can't silently return nothing. It can save the filter as a local query.

** Saved queries

Name local queries in =pearl-saved-queries=, then run them with =pearl-run-saved-query=:

#+begin_src elisp
(setq pearl-saved-queries
      '(("My open work" :filter (:assignee :me :open t) :sort updated :order desc)
        ("Open bugs"     :filter (:labels ("bug") :open t) :sort priority :order asc)))
#+end_src

Each entry is a filter plist plus optional =:sort= (=updated=, =priority=, or =title=) and =:order= (=asc= / =desc=). Sorting happens after fetch, so a refresh always lays headings out the same way. Queries are AND-only; for OR logic, build a Linear Custom View and run it with =run-view=.

* Editing issues

All issue commands work from anywhere inside an issue's subtree.

** Description and title

Edit the description in the body, then =M-x pearl-sync-current-issue=. The push is gated: unchanged since fetch sends nothing; a local edit against an unchanged remote pushes; if the remote also changed since the fetch the push is refused and the conflict reported (refresh to reconcile). =pearl-sync-current-issue-title= does the same for the heading title (note: square brackets are stripped from titles, so a synced title drops them).

** Fields

| Command | Effect |
|---------+--------|
| =pearl-set-priority= | None / Urgent / High / Medium / Low |
| =pearl-set-state= | a workflow state from the issue's team |
| =pearl-set-assignee= | a team member |
| =pearl-set-labels= | team labels (empty selection clears) |

Each completes by display name, resolves to the Linear id, pushes, and updates the drawer.

** Comments

Comments render oldest-first under a =*** Comments= subtree, in both the bulk list and a single-issue refresh. =pearl-add-comment= posts a new comment and appends it.

You can also edit your *own* comments. Edit a comment's body in place, then =M-x pearl-edit-current-comment= from inside its subtree. The push is gated like the description sync: unchanged sends nothing, a clean edit pushes, and a both-sides-changed case is refused (refresh to reconcile). Matching Linear's permissions, only comments you authored are editable — others (and bot or integration comments) are refused without a network call. Editability is shown by color: your own comments render green, everyone else's render greyed. Run =M-x pearl-highlight-comments= to recolor a buffer by hand.

** Other commands

| Command | Effect |
|---------+--------|
| =pearl-open-current-issue= | open the issue's URL in the browser |
| =pearl-open-current-view-in-linear= | open the active view's URL |
| =pearl-delete-current-issue= | delete the issue (confirms; soft delete to Trash) |
| =pearl-new-issue= | create an issue |
| =pearl-clear-cache= | clear the team/state/collection/view lookup caches |

* State mapping and org TODO sync

=pearl-state-to-todo-mapping= maps Linear workflow states to org TODO keywords for *rendering only* — it no longer decides which issues are fetched (inclusion is server-side via the filter). Default:

#+begin_src elisp
'(("Todo" . "TODO")
  ("In Progress" . "IN-PROGRESS")
  ("In Review" . "IN-REVIEW")
  ("Backlog" . "BACKLOG")
  ("Blocked" . "BLOCKED")
  ("Done" . "DONE"))
#+end_src

Customize it to match your workflow, and make sure your =org-todo-keywords= include every keyword you map to. With org sync enabled, changing an issue's TODO keyword pushes the corresponding Linear state:

#+begin_src elisp
(add-hook 'find-file-hook
          (lambda ()
            (when (and buffer-file-name
                       (string-equal (file-truename buffer-file-name)
                                     (file-truename pearl-org-file-path)))
              (pearl-enable-org-sync))))
#+end_src

=pearl-enable-org-sync= / =pearl-disable-org-sync= toggle the save-and-TODO-change hooks for the active file.

* Customization

| Variable | Purpose |
|----------+---------|
| =pearl-org-file-path= | the active output file |
| =pearl-state-to-todo-mapping= | Linear state ↔ org keyword (render/sync) |
| =pearl-saved-queries= | named local queries |
| =pearl-default-team-id= | default team for issue creation |
| =pearl-max-issue-pages= | pagination cap (default 10) |
| =pearl-request-timeout= | API request timeout (seconds) |
| =pearl-fold-after-update= | re-fold the page after a fetch/refresh (default on) |
| =pearl-surface-buffer= | bring the active buffer to a window after a command (default on) |
| =pearl-surface-select-window= | also move focus to that window (default off) |
| =pearl-debug= | enable debug logging |

* FAQ

** I edited the title in the heading and ran the description sync, but the title didn't change.

Title and description push through separate commands. =pearl-sync-current-issue= pushes the body; the heading title is =pearl-sync-current-issue-title= (menu =t=). Fields and comments are their own commands too — each edit pushes independently.

** My title lost its square brackets after syncing.

The renderer strips =[= and =]= from titles so org doesn't misparse them, so the heading holds the stripped form and a title push sends that. =Fix [URGENT] bug= becomes =Fix URGENT bug=. Keep brackets out of titles you mean to round-trip.

** A markdown heading or =*italic*= in a description turned into bold after I synced it.

Two markdown constructs are intentionally lossy on the round-trip: a markdown =# heading= renders as a bold line on fetch and pushes back as bold (it never returns to a heading), and single-asterisk =*italic*= is read as bold. Edit a description containing either and push, and it comes back rewritten. Use =**bold**= and real Org sub-structure deliberately; don't rely on md headings surviving.

** My comment edit was refused with "You can only edit your own comments."

By design — Pearl matches Linear's permissions. Only comments you authored are editable; another person's comment, or a bot or integration comment, is refused with no API call. Your own comments render green, everyone else's greyed.

** A sync or comment edit was refused as a conflict.

The push is gated three ways: unchanged since fetch sends nothing; a local edit against an unchanged remote pushes; if the remote also changed since your last fetch, the push is refused and the conflict reported. Refresh to reconcile, or take the use-local / use-remote / merge prompt — any destructive choice stashes your local text in =*pearl-conflict-backup*= first, so nothing is lost.

** I hand-edited a drawer field (priority, state, assignee, labels) and it didn't push.

=LINEAR-*= drawer fields change by command, not by hand: =pearl-set-priority=, =-state=, =-assignee=, =-labels=. Each resolves a display name to the Linear id, pushes, and rewrites the drawer. A value typed into the drawer by hand won't push and gets overwritten on the next refresh.

** My fetch seems to be missing issues.

The fetch is capped at =pearl-max-issue-pages= (default 10 pages × 100 = 1000 issues). A capped fetch sets =#+LINEAR-TRUNCATED: yes= in the header. Raise =pearl-max-issue-pages= if you're assigned more.

** Names (team, state, assignee) are stale after I renamed them in Linear.

Pearl caches those lookups so it doesn't hit the network on every render. Run =M-x pearl-clear-cache=.

** The page collapses after every refresh — can I keep it expanded?

After a fetch or refresh Pearl re-folds the page to its =#+STARTUP= visibility (issue headings shown; descriptions, comments, and drawers folded), because =#+STARTUP= on its own only applies on a file's first visit. Set =pearl-fold-after-update= to nil to leave the buffer however you had it.

** Can I hide the property drawers completely, not just fold them?

Pearl folds drawers with the rest of the page, but an expanded issue still shows the one-line =:PROPERTIES:= stub. For a tidier look, [[https://github.com/jxq0/org-tidy][org-tidy]] replaces each drawer with a small glyph. It composes with Pearl rather than fighting it: Pearl folds the outline (descriptions and comments) to a scannable list, and org-tidy collapses the drawers on top — even on an expanded issue. Editing still works, since org-tidy only adds display overlays and never touches the drawer text, and a Pearl fetch re-applies it cleanly. Turn it on with =M-x org-tidy-mode= (or =global-org-tidy-mode=); if a partial refresh ever leaves a drawer showing, =M-x org-tidy= re-tidies.

* Troubleshooting

- =M-x pearl-test-connection= — check the API key and connectivity.
- =M-x pearl-check-setup= — confirm the key loaded.
- =M-x pearl-toggle-debug= (or =(setq pearl-debug t)=) — log requests and responses to =*Messages*=.
- Stale names after renaming things in Linear → =M-x pearl-clear-cache=.

* Contributing

Contributions are welcome — open an issue or a pull request.

* Acknowledgments

Based on and inspired by Gael Blanchemain's linear-emacs.

* License

GPL-3.0. See [[file:LICENSE][LICENSE]].