aboutsummaryrefslogtreecommitdiff
path: root/README.org
blob: c79ecc9607aaa0197efd72541eb81b283506445a (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
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
* PEARL Edits and Reflects Linear

[[#features][Features]] | [[#installation][Installation]] | [[#quick-start][Quick Start]] | [[#commands][Commands]] | [[#configuration][Configuration]] | [[#development--testing][Development & Testing]] | [[#faq][FAQ]] | [[#history][History]] | [[#license][License]]

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

" /We like lists because we don't want to die./ "
— /Umberto Eco/

Pearl (backronym: *Pearl Edits and Reflects Linear*) brings Linear issues into Emacs as a working Org file. Fetch your open issues, a project, a Linear Custom View, an ad-hoc filter, or a local view; Pearl renders each issue as an Org heading with the description and comments in the body and Linear metadata in a namespaced property drawer. Edit what you need in the buffer, then save the issue; Pearl reconciles each changed field against the remote with a conflict check.

** Features
:PROPERTIES:
:CUSTOM_ID: features
:END:

- Fetch open issues, project issues, server-side Linear Custom Views, ad-hoc filters, or named local views
- Read issues as an Org outline: title as heading, description in the body, comments as a chronological subtree
- Keep structured fields in =LINEAR-*= properties: id, URL, team, state, priority, assignee, labels, timestamps, and sync hashes
- Edit any field -- title, description, priority, state, assignee, labels, your own comments -- in the buffer, then save; Pearl reconciles each change through a conflict gate
- Set priority, state, assignee, and labels by command, using Linear ids behind display-name completion
- Add and delete your own comments, create issues, delete issues, and open the current issue or view in Linear
- Refresh the active view from the source recorded in the file, or refresh one issue at point
- Render Linear workflow states as Org TODO keywords
- Work multiple Linear workspaces from one Emacs: named accounts, a safe switch, per-file ownership, and a mode-line indicator
- Use one transient dispatcher, =M-x pearl-menu=, for the whole command surface
- [[file:TESTING.org][Well-tested]] with isolated ERT files, request fixtures, and coverage support

** Installation
:PROPERTIES:
:CUSTOM_ID: installation
:END:

Pearl requires Emacs 27.1+, Org, a Linear API key, and the =request=, =dash=, =s=, and =transient= packages. =transient= ships with Emacs 28+, and package managers handle the rest.

*** MELPA

Pearl is not on MELPA yet.

*** package-vc-install (Emacs 29+)

#+begin_src emacs-lisp
  (unless (package-installed-p 'pearl)
    (package-vc-install "https://git.cjennings.net/pearl.git"))
#+end_src

*** use-package with =:vc= (Emacs 29+)

#+begin_src emacs-lisp
  (use-package pearl
    :vc (:url "https://git.cjennings.net/pearl.git" :rev :newest)
    :commands (pearl-menu pearl-list-issues pearl-run-linear-view pearl-create-issue)
    :bind ("C-c L" . pearl-menu)
    :custom
    (pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory))
    :config
    (pearl-load-api-key-from-env))
#+end_src

*** Straight

#+begin_src emacs-lisp
  (straight-use-package
   '(pearl :type git :repo "https://git.cjennings.net/pearl.git"))
#+end_src

*** Doom Emacs

In =packages.el=:

#+begin_src emacs-lisp
  (package! pearl
    :recipe (:type git :repo "https://git.cjennings.net/pearl.git" :files ("*.el")))
#+end_src

In =config.el=:

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

*** Manual

#+begin_src bash
  git clone https://git.cjennings.net/pearl.git
#+end_src

#+begin_src emacs-lisp
  (add-to-list 'load-path "/path/to/pearl")
  (require 'pearl)
#+end_src

Install =request=, =dash=, and =s= first if your package manager did not already pull them in.

** Quick Start
:PROPERTIES:
:CUSTOM_ID: quick-start
:END:

1. Create a Linear API key from Linear's Settings -> Account -> API -> Personal API Keys.

2. Put it somewhere Pearl can read it. The simplest development setup is:

   #+begin_src sh
     export LINEAR_API_KEY=lin_api_...
   #+end_src

   Then load it from Emacs:

   #+begin_src emacs-lisp
     (pearl-load-api-key-from-env)
   #+end_src

   For normal use, keep the key in =auth-source= instead:

   #+begin_example
   machine api.linear.app login apikey password YOUR_API_KEY
   #+end_example

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

3. Choose the Org file Pearl owns:

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

4. Run =M-x pearl-menu=, or start with =M-x pearl-list-issues=.

Pearl writes one active Org file. Running a different view or filter replaces that file's contents after checking for dirty buffers. Refresh commands reuse the source stored in the file header.

** Commands
:PROPERTIES:
:CUSTOM_ID: commands
:END:

*** Command menu

=M-x pearl-menu= opens a transient dispatcher. Issue actions are grouped as save, edit, create, and delete; workspace actions as fetch, views, buffer, and setup. Bind that command if you use Pearl regularly:

#+begin_src emacs-lisp
  (global-set-key (kbd "C-c L") #'pearl-menu)
#+end_src

*** Prefix keymap and pearl-mode

Pearl is fully keyboard-drivable. =pearl-mode=, a minor mode, turns on automatically in any buffer Pearl renders (it detects the =#+LINEAR-SOURCE= header) and binds the command keymap under =pearl-keymap-prefix= (default =C-; L=). So in a fetched buffer the keys are live with no setup. Change the prefix, or turn it off, with:

#+begin_src emacs-lisp
  (setq pearl-keymap-prefix "C-c l")   ; or nil to bind nothing
#+end_src

The hot-path commands sit one key under the prefix; the rest are grouped into sub-maps, one per action verb or noun. Fetch is issue sources only; views, account switching, and setup each have their own group. The common ones appear in both places, so =d= and =e d= both edit the description.

| Key       | Command                                                              |
|-----------+----------------------------------------------------------------------|
| =l=         | open the default view (my open issues until you set one)            |
| =g=         | refresh the view                                                     |
| =r=         | refresh the issue at point                                           |
| =s= / =S=     | save the issue at point / save every issue in the file             |
| =d=         | edit the description                                                  |
| =m=         | open the full transient menu                                         |
| =f= ...     | fetch (issue sources): =s= pick source, =o= open issues, =p= by project, =f= filter |
| =v= ...     | views: =l= run local, =L= run Linear, =c= create, =e= edit, =k= delete, =u= publish, =U= publish current, =d= save Linear view locally, =D= set default |
| =e= ...     | edit: =d= description, =s= state, =a= assignee, =l= labels, =c= comment    |
| =c= ...     | create: =t= issue, =c= comment                                          |
| =k= ...     | delete: =t= issue, =c= comment                                          |
| =o= ...     | open: =i= issue in browser, =v= view in Linear                           |
| =y= ...     | copy: =u= issue URL                                                      |
| =w= ...     | workspace: =a= switch account, =t= test connection, =c= check setup, =!= toggle debug, =x= clear cache |

To reach the map outside a Pearl buffer, bind it globally as well:

#+begin_src emacs-lisp
  (global-set-key (kbd "C-; L") pearl-prefix-map)
#+end_src

With =which-key=, each step shows a labeled menu. Every command is also available through =M-x=.

*** The default view

The hot key =l= (=pearl-open-default-view=) opens whatever you've set as your default. Out of the box that's your open issues, so =l= behaves exactly as it always has until you choose otherwise. The literal my-open-issues fetch is still on =C-; L f o= if you want it regardless of the default.

Set the default with =pearl-set-default-view= (=C-; L v D=, or =.= in the transient's Views group). From a buffer that's showing a local view it offers that view; otherwise it prompts over your local-view names, with a =[ My open issues ]= entry at the top that clears the default. The choice is saved through Customize, so it persists across restarts. To default to a Linear Custom View, save it locally first (=pearl-save-linear-view-locally=) and set the resulting local view.

The default is =pearl-default-view= (a local-view name, or nil for my open issues). Under [[*Multiple accounts][multiple accounts]] it's per-account instead: each account carries its own =:default-view=, so =l= opens the right view for whichever account is active. If the named view has since been deleted, =l= falls back to my open issues with a message rather than erroring.

*** Sources

A *source* is anything Pearl can fetch from: a Linear favorite, a Custom View, a project / cycle / label / user, a local view, or an ad-hoc filter. The everyday front door is =pearl-pick-source= (=C-; L f s=, or =P= in the transient), which lists all your Linear favorites first (in their Linear sort order) and then your local views, each tagged by kind:

#+begin_example
  [view] Active sprint bugs
  [project] Orchestration Dashboard
  [user] Vrezh Mikayelyan
  [label] security
  [local] My open work
  [local → Linear:Eng] Active sprint bugs (synced)
  [local → Linear:Personal] My ICEBOX scratchpad (synced)
#+end_example

Pick one and it fetches. List-capable favorites (Custom View / project / cycle / label / user) resolve to the existing filter or view fetch and render into the active file; non-list favorites (issue, document, dashboard, ...) open in the browser instead. Favorites are picker entries, never persisted -- a chosen favorite resolves to a concrete filter/view source before rendering, so refresh re-runs that resolved source and stays stable even if your favorites list later changes. Label and user favorites resolve by id, not by name or email, so renames in Linear don't break a saved fetch.

A local view you've published to Linear (see [[*Publishing a local view as a Linear view]] below) renders as =[local → Linear:<Team or Personal>] Name= so you can see at a glance that it's published and to which scope. The arrow reads "this local view is mirrored there." The local view is the source of truth: picking it runs *your local filter*, not the Linear mirror, so editing the local view and running it shows your edits even before you publish them. To run the server-side Linear view instead, use =pearl-run-linear-view=. Plain =[local]= entries are local-only. =[local → Linear:?]= means the entry is published but Pearl couldn't resolve the team id (deleted or renamed on Linear) -- running still works, the label is just flagging stale scope metadata.

If the favorites fetch fails (network/auth/transport), the picker still offers any local views -- a failed fetch is not the same as "no favorites." With neither favorites nor local views, =pearl-pick-source= refuses with a clear message naming the missing setup.

*** Publishing a local view as a Linear view

A local view is great for your own quick filters, but the team can't see it and the Linear web UI can't run it. =pearl-publish-local-view= (=C-; L v u=, or =u= in the transient's Views group) promotes a local view to a Linear Custom View so it's visible alongside your sidebar favorites and the rest of the team's views. This is the *copy up* direction of the lifecycle.

Two commands publish:

| Command                            | Binding                              | What it does                                                       |
|------------------------------------+--------------------------------------+--------------------------------------------------------------------|
| =pearl-publish-local-view=   | =C-; L v u=, =u= in the transient Views group | Pick a local view by name and publish (or update) it on Linear    |
| =pearl-publish-current-view=       | =C-; L v U=, =U= in the transient Views group | Read the buffer's =#+LINEAR-SOURCE=; if it names a local view, publish that one |

The first time you publish a local view, Pearl asks where the view should live with one enriched prompt that spells out the full end-state per option:

#+begin_example
  Where does this view live?
    [ Team: Engineering, visible to the team ]
    [ Personal, only I see it ]
    [ Team: Engineering, only I see it ]
    [ Team: Marketing, visible to the team ]
    [ Team: Marketing, only I see it ]
    ...
    [ Cancel. ]
#+end_example

The default is the team in your filter's =:team= key (if any) shared to that team, else =[ Personal, only I see it ]=. If a view with the same name already exists in the chosen scope, Pearl prompts =Replace? Rename? Cancel?= -- Replace updates the existing view's filter by id (preserving its url and anyone's favorites on it), Rename re-prompts for a different name, Cancel aborts cleanly.

After a successful publish, the local-view entry gains four metadata keys (=:linear-view-id=, =:linear-view-team-id=, =:linear-view-shared=, =:linear-view-synced-at=) plus =:linear-view-url= so =pearl-open-current-view-in-linear= can dispatch to the browser. Re-publishing the same local view calls =customViewUpdate= against the stored id -- the Linear view is overwritten with whatever your local filter says now. This is one-way push by design: the verb is "publish my version." If you edit the view on Linear's side and then re-publish from Pearl, your local plist wins.

Pearl's =:sort= / =:order= on a local view don't publish up in v1 -- Linear's =CustomView= API has no sort input, so a published view renders in whatever order Linear's defaults give it. If sort order is load-bearing for the view, set it in the Linear web UI after the first publish.

=pearl-delete-local-view= (=C-; L v k=) on a published entry asks a second question after the local-delete confirmation: also delete the linked Linear view? Yes calls =customViewDelete=, which Linear handles as a soft delete (recoverable from the workspace trash). No unlinks the entry locally and leaves the Linear view in place. If the Linear delete fails (permissions, network), Pearl prompts to drop the local entry anyway -- accepting orphans the Linear view, and the message names the view id explicitly so you can clean it up by hand. Under multiple accounts, publish and delete refuse a local view tagged to a non-active account before touching Linear.

*** Saving a Linear view locally

The other direction -- *copy down* -- forks a Linear view into a new, editable local view. =pearl-save-linear-view-locally= (=C-; L v d=, or =D= in the transient's Views group) lists your favorited Linear views, fetches the chosen view's filter, and reverse-compiles it into a Pearl authoring filter you can then edit and rename. The new local view is independent: it carries no tracking link, so editing or renaming it never touches the Linear view (publish it back later to push your changes up as a new Linear view).

Linear stores a view's filter as an =and=/=or= tree that's richer than Pearl's AND-only local model. Copy-down works for any view inside that model -- a team / state / label / assignee filter, the common case. When a view uses something Pearl can't represent (OR logic, a label parent, a due-date filter, or a multi-value filter on a singular dimension like project or assignee), copy-down *refuses and names the construct* rather than silently saving a filter that matches a different set of issues; run such a view directly with =pearl-run-linear-view= instead. To copy down *all* Linear views (not just favorited ones) is a later addition; v1 lists favorites.

*** Creating and editing local views

| Command                      | Binding        | What it does                                          |
|------------------------------+----------------+------------------------------------------------------|
| =pearl-create-local-view=      | =C-; L v c=     | Build a filter interactively and save it as a local view |
| =pearl-edit-local-view=        | =C-; L v e=     | Edit a local view's filter, name, sort, and order, preserving its metadata |
| =pearl-save-linear-view-locally= | =C-; L v d=  | Copy a favorited Linear view down into a new editable local view |

=pearl-list-issues-filtered= also offers to save its ad-hoc filter as a local view at the end. Create, edit, and copy-down all prompt Replace / Rename / Cancel on a name collision, and under multiple accounts stamp the active account on a new view and refuse a cross-account edit.

*** Fetching and refreshing

| Command                      | What it does                                     |
|------------------------------+--------------------------------------------------|
| =pearl-pick-source=            | Pick a Linear favorite or local view and fetch it |
| =pearl-list-issues=            | Fetch your open issues                           |
| =pearl-list-issues-by-project= | Fetch every open issue in a chosen project (all assignees) |
| =pearl-list-issues-filtered=   | Build an ad-hoc issue filter interactively       |
| =pearl-run-linear-view=               | Run a Linear Custom View server-side             |
| =pearl-run-local-view=        | Run a named local view from =pearl-local-views= |
| =pearl-publish-local-view= | Publish (or update) a local view as a Linear Custom View |
| =pearl-publish-current-view= | Publish the current buffer's local view (reads =#+LINEAR-SOURCE=) |
| =pearl-delete-local-view=     | Delete a local view (and optionally its linked Linear view) |
| =pearl-refresh-current-view=   | Re-run the source recorded in the active file    |
| =pearl-refresh-current-issue=  | Re-fetch the issue at point                      |

Ad-hoc filtering starts with a team, then completes states, projects, and labels from that team's actual Linear values. The State and Labels prompts are multi-select (comma-separated): pick several states to match issues in any of them, e.g. =(:state ("Todo" "In Review"))=. The assignee prompt offers =me=, a specific =member= (resolved to a user id), or =any= (no scoping). Local views are local Lisp data:

#+begin_src emacs-lisp
  (setq pearl-local-views
        '(("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

Sorting is local and deterministic. Query filters are AND-only; use a Linear Custom View for OR-heavy logic.

=pearl-refresh-current-view= merges the fresh fetch into the buffer by issue id rather than rebuilding it: issues update in place, new matches are appended, and ones no longer in the result are dropped. Your view survives: point and the fold state of every untouched issue stay put, and only the subtrees that actually changed get re-folded. Editing an issue and refreshing won't collapse the subtree you were working in or scroll you back to the top.

*** Editing issues

Pearl has one write path. Edit an issue however you like in the buffer, then save it -- Pearl diffs each field against what it last fetched and pushes only what changed. Nothing pushes the moment you pick a value, and there is no per-field "push" command to remember. All issue commands work from anywhere inside an issue subtree.

| Command                           | What it does                                          |
|-----------------------------------+-------------------------------------------------------|
| =pearl-save-issue=                  | Save every changed field of the issue at point      |
| =pearl-save-all=                    | Save every changed issue in the file (confirms once) |
| =pearl-edit-description=            | Edit the description in a focused compose buffer     |
| =pearl-edit-state=                  | Pick a workflow state (reaches any of the team's)    |
| =pearl-edit-assignee=               | Pick a team member as assignee                       |
| =pearl-edit-labels=                 | Pick labels; an empty selection clears them          |
| =pearl-create-comment=                 | Add a new Linear comment                             |
| =pearl-edit-current-comment=        | Edit one of your own comments                        |
| =pearl-delete-current-comment=      | Delete one of your own comments after confirming     |
| =pearl-delete-current-issue=        | Soft-delete the current issue after confirmation     |
| =pearl-open-current-issue=          | Open the issue URL in a browser                      |
| =pearl-open-current-view-in-linear= | Open the active view in Linear                       |

How each field is edited:

- *Title and description* -- type in the heading and the body. =pearl-edit-description= pops a focused compose buffer if you'd rather not edit inline.
- *Priority* -- the Org priority cookie (=C-c ,=, or =S-up= / =S-down=). =[#A]/[#B]/[#C]/[#D]= are Urgent/High/Medium/Low; no cookie is None.
- *State* -- =pearl-edit-state=, completing over the team's workflow states.
- *Assignee and labels* -- =pearl-edit-assignee= / =pearl-edit-labels=, completing over the team's members and labels. Labels also render as Org tags on the heading (=:bug:backend:=), so you can filter, sparse-tree, and build agendas on them. The =:LINEAR-LABELS:= drawer stays the source of truth; hand-edited heading tags are ignored and rewritten from Linear on the next change or fetch, so change labels with =pearl-edit-labels=.
- *Comments* -- edit your own in place, or =pearl-create-comment= for a new one.

Picking a constrained field writes the value into the buffer and marks it changed; it doesn't push until you save. The display name or label text is there to read -- Pearl reconciles by the underlying id, and a refresh rewrites the display from the remote, so hand-editing the visible name has no effect.

At save, each field runs through a conflict gate. An unchanged local value sends nothing; a local change against an unmoved remote pushes; if both moved since fetch, Pearl refuses to clobber either side and asks what to do. Free-text fields (description, title, comments) offer an smerge merge; atomic fields (state, priority, assignee, labels) offer use-mine / use-theirs. Destructive text choices stash the local text in =*pearl-conflict-backup*= first.

Only comments you authored are editable or deletable. Pearl refuses edits and deletes to comments from another person, a bot, or an integration before making an API call. A comment delete is permanent (Linear has no restore for it), so deleting one with unsaved local edits prompts to confirm discarding them.

*** State and the TODO keyword

A fetched issue renders with a TODO keyword derived from its Linear state name: the name is slugified (upcased, non-alphanumeric runs collapsed to hyphens), so "In Progress" becomes =IN-PROGRESS= and "Dev Review" becomes =DEV-REVIEW=. The buffer's =#+TODO= line is built from the real workflow states of the teams on display, so every keyword you see is a state that team actually has, partitioned active / done by Linear's state type.

You change an issue's state two ways, both reconciled and pushed at the next save:

- Cycle the keyword with =C-c C-t= (or =S-<right>= / =S-<left>=). The keyword you cycle to is matched back to a team state by the same slug rule, so cycling =TODO= → =IN-PROGRESS= moves the issue to the "In Progress" state. A keyword no team state slugifies to can't be resolved, so that save is reported skipped.
- =pearl-edit-state= completes over every state on the team and writes the keyword for you. Use it to reach a state whose keyword you don't remember, or one the visible keyword set doesn't cover.

** The Org File
:PROPERTIES:
:CUSTOM_ID: the-org-file
:END:

A fetched Pearl file is intentionally readable. The header records the source, run time, filter summary, count, and whether pagination truncated the result. Issues sit under one top-level view heading:

#+begin_src org
  #+title: Linear - My open issues
  #+STARTUP: show2levels
  #+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-COUNT: 12
  #+LINEAR-TRUNCATED: no

  * 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-STATE-NAME:       In Progress
  :LINEAR-ASSIGNEE-NAME:    Craig
  :LINEAR-LABELS:           [bug, p1]
  :LINEAR-DESC-SHA256:      <last-fetched markdown hash>
  :LINEAR-DESC-ORG-SHA256:  <rendered Org body hash>
  :END:

  The issue description renders here as Org and can be edited in place.

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

The =LINEAR-*= properties store both ids and display names so common commands do not need a network lookup just to render. The hash properties are provenance for conflict-aware sync.

*** Fidelity to Linear
:PROPERTIES:
:CUSTOM_ID: fidelity-to-linear
:END:

Anything on the page that is Linear's data renders verbatim — the buffer shows the same text you would see opening the issue in Linear itself. Titles, the view name, assignee and label names, comment bodies: Pearl does not re-case, reword, or tidy them. There are exactly two transformations, and both are forced by Org's own syntax rather than chosen:

- *State names become TODO keywords.* Org keywords cannot contain spaces, so "Dev Review" renders as the keyword =DEV-REVIEW=. The real state name is preserved verbatim in the =:LINEAR-STATE-NAME:= drawer property.
- *Descriptions and comments convert between Markdown and Org.* Linear stores them as Markdown; the buffer renders them as Org and converts back on save. This is a representation translation, not an edit of the content, and round-trips for the supported subset.

If you prefer a tidier outline, =pearl-title-case-headings= opts issue titles into smart title case (off by default, so titles match Linear out of the box).

** Configuration
:PROPERTIES:
:CUSTOM_ID: configuration
:END:

Most users only need an API key and an output path. The rest are knobs for teams with large issue sets or stronger preferences about window behavior.

| Variable                    | Purpose                                           |
|-----------------------------+---------------------------------------------------|
| =pearl-api-key=               | Linear API key (single-account)                   |
| =pearl-org-file-path=         | Active Org output file                            |
| =pearl-default-team-id=       | Default team for issue creation                   |
| =pearl-accounts=              | Named accounts for multiple workspaces            |
| =pearl-default-account=       | Account made active at first need                 |
| =pearl-local-views=         | Named local views                         |
| =pearl-max-issue-pages=       | Pagination cap, 100 issues per page               |
| =pearl-request-timeout=       | Synchronous request timeout in seconds            |
| =pearl-fold-after-update=     | Re-fold the active page after fetch/refresh       |
| =pearl-title-case-headings=   | Opt-in smart title case for issue titles (off)    |
| =pearl-surface-buffer=        | Show the active buffer after a command updates it |
| =pearl-surface-select-window= | Move focus to the surfaced buffer                 |
| =pearl-compose-window-side=   | Side the compose/conflict buffer opens from       |
| =pearl-compose-window-size=   | Size of that side window (fraction or lines)      |
| =pearl-debug=                 | Log request/response details to =*Messages*=        |

If a fetch stops at the pagination cap, Pearl writes =#+LINEAR-TRUNCATED: yes= in the file header. Raise =pearl-max-issue-pages= if your result set is larger than the default 1000 issues.

*** Which browser opens for Linear URLs

Pearl hands every URL it opens (issue links, view links, browser-only favorites) to Emacs's =browse-url=, which dispatches to whatever =browse-url-browser-function= names. Pearl never picks a browser of its own, so the answer to "why this browser?" is always your Emacs setting, not pearl. Your system default ($BROWSER, xdg-mime) only matters when =browse-url-browser-function= delegates to it.

Common shapes:

| Value                                       | Behavior                                                       |
|---------------------------------------------+----------------------------------------------------------------|
| ='browse-url-default-browser=               | Emacs autodetects (usually correct)                            |
| ='browse-url-xdg-open=                      | Honor the desktop default (=xdg-mime= for http/https)          |
| ='browse-url-firefox= / =-chrome= / =-chromium= | Always that browser                                            |
| ='browse-url-generic= + =browse-url-generic-program= | Any command you name                                           |

For per-URL routing (e.g. "Linear goes to the work-account browser, everything else to my personal one"), set =browse-url-handlers= with a list of =(REGEXP . FUNCTION)= pairs.

** Multiple accounts
:PROPERTIES:
:CUSTOM_ID: multiple-accounts
:END:

If you work more than one Linear workspace — say a work account and a personal one — set =pearl-accounts= and switch between them with =pearl-switch-account= (=C-; L w a=, or =M-x pearl-switch-account=). Each account names where its key is found, which Org file holds its issues, and (optionally) a default team:

#+begin_src elisp
(setq pearl-accounts
      '(("work"     :api-key-source (:auth-source :host "api.linear.app" :user "work")
                    :org-file "~/org/work-linear.org"
                    :default-team-id "TEAM_WORK")
        ("personal" :api-key-source (:env "LINEAR_PERSONAL_API_KEY")
                    :org-file "~/org/personal-linear.org")))
(setq pearl-default-account "work")
#+end_src

=:api-key-source= says how the key is *found*, not the key itself. Three forms:

| Form                                    | Where the key comes from                          |
|-----------------------------------------+---------------------------------------------------|
| =(:auth-source :host H :user U)=          | =~/.authinfo.gpg= (the documented default)          |
| =(:env "VAR")=                            | the named environment variable                    |
| =(:literal "lin_...")=                    | the string inline (an escape hatch)               |

The auth-source form keeps the key out of your config. An =~/.authinfo.gpg= line for the example above:

#+begin_example
machine api.linear.app login work password lin_api_xxxxxxxx
#+end_example

A resolved key is never written back through Customize and never logged. =pearl-load-api-key-from-env= is a legacy single-account convenience and refuses once =pearl-accounts= is set — put =:api-key-source (:env "...")= in the account instead.

*** Switching, ownership, and the indicator

=pearl-switch-account= makes an account active, clears the per-workspace lookup caches so the next fetch resolves against the new workspace, and visits that account's Org file. The active account shows in the mode line as =Pearl[work]=.

Each rendered file is stamped with =#+LINEAR-ACCOUNT:= naming the workspace that owns it. Pearl refuses to run a command from a file owned by a different account than the active one — it names both and tells you to switch first — so a work edit can't land under personal credentials. A file with no marker (a legacy file, or one you created before configuring accounts) lets read/refresh commands through and stamps ownership on the next refresh, but refuses a mutation until then.

Leave =pearl-accounts= unset for single-account use; everything works off =pearl-api-key= and =pearl-org-file-path= exactly as before.

*** Local views across accounts

=pearl-local-views= is one shared list. A local-view entry may carry an optional =:account= so it only runs under that account:

#+begin_src elisp
("My work bugs" :account "work"
                :filter (:team "ENG" :label "bug" :assignee :me))
#+end_src

Running it under a different active account refuses before any lookup or fetch. A local view *without* =:account= is shared and resolves its team / state / label names against whatever account is active — so the same name can mean different things across workspaces. Tag the ones that must not cross.

** Development & Testing
:PROPERTIES:
:CUSTOM_ID: development--testing
:END:

Clone the repo and install the Eask-managed dependencies:

#+begin_src bash
  git clone https://git.cjennings.net/pearl.git
  cd pearl
  make setup
#+end_src

Useful development targets:

| Target                                 | What it does                                          |
|----------------------------------------+-------------------------------------------------------|
| =make test=                              | Run unit and integration tests, excluding =:slow= tests |
| =make test-all=                          | Run every test, including =:slow= tests                 |
| =make test-unit=                         | Run unit tests only                                   |
| =make test-integration=                  | Run integration tests only                            |
| =make test-file FILE=mapping=            | Run one test file by fuzzy match                      |
| =make test-one TEST=priority=            | Run one test by fuzzy match                           |
| =make test-name TEST='test-pearl-map-*'= | Run tests matching an ERT selector                    |
| =make coverage=                          | Generate undercover/simplecov coverage data           |
| =make compile=                           | Byte-compile =pearl.el=                                 |
| =make lint=                              | Run the lint/checkdoc path from the test harness      |
| =make validate=                          | Check parentheses across source and tests             |
| =make clean=                             | Remove test artifacts and coverage output             |

Each test file runs in its own Emacs batch process for isolation. See [[file:TESTING.org][TESTING.org]] for the full test guide, naming conventions, fixture helpers, and coverage notes.

** FAQ
:PROPERTIES:
:CUSTOM_ID: faq
:END:

*** I edited the heading title and synced the description. Why did the title stay the same?

Titles and descriptions push through separate commands. Use =pearl-sync-current-issue= for the body and =pearl-sync-current-issue-title= for the heading title.

*** Why did square brackets disappear from a synced title?

Pearl strips =[= and =]= from titles before rendering so Org does not misparse them. A title like =Fix [URGENT] bug= round-trips as =Fix URGENT bug=.

*** Why did a Markdown heading or single-asterisk italic change after syncing?

The Markdown-to-Org round trip is intentionally lossy for a few constructs. Markdown =# heading= renders as a bold line, and single-asterisk =*italic*= is read as bold. Use these constructs carefully in descriptions you plan to edit from Org.

*** Why did Pearl refuse my comment edit?

Linear only lets you edit comments you authored. Pearl checks that before pushing and refuses edits to other people's comments, bot comments, and integration comments.

*** Why did Pearl refuse a description, title, or comment sync as a conflict?

The local text and remote text both changed since the last fetch. Refresh to reconcile, or use the conflict prompt. Pearl stashes local text before any destructive resolution.

*** Why did a hand-edited drawer field get overwritten?

Drawer fields are generated metadata. Change priority, state, assignee, and labels through Pearl commands so display names can resolve to Linear ids correctly.

*** Why are renamed teams, states, labels, or assignees stale?

Pearl caches Linear lookup tables. Run =M-x pearl-clear-cache=.

*** Can I keep the file expanded after refresh?

Yes. Set =pearl-fold-after-update= to nil.

** Troubleshooting
:PROPERTIES:
:CUSTOM_ID: troubleshooting
:END:

- =M-x pearl-check-setup= checks whether the API key is loaded.
- =M-x pearl-test-connection= checks API connectivity.
- =M-x pearl-toggle-debug= enables request/response logging in =*Messages*=.
- =M-x pearl-clear-cache= refreshes cached names after Linear-side changes.

** History
:PROPERTIES:
:CUSTOM_ID: history
:END:

Pearl is based on and inspired by Gael Blanchemain's [[https://github.com/gael/linear-emacs][linear-emacs]]. The package has since grown into a separately maintained Linear workflow for Org, including broader fetch modes, editable descriptions and comments, conflict-aware sync, view refresh, command menus, and a dedicated test suite.

Bug reports, feature requests, and pull requests are welcome.

** License
:PROPERTIES:
:CUSTOM_ID: license
:END:

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