aboutsummaryrefslogtreecommitdiff
path: root/README.org
blob: cc283f20c9fcf356f9c128bab6785b9c0f35126b (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
* 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 saved local query; 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 saved queries
- 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-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-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 query or view 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, view, 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. The common ones appear in both places, so =d= and =e d= both edit the description.

| Key       | Command                                                              |
|-----------+----------------------------------------------------------------------|
| =l=         | list my open issues                                                  |
| =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: =s= pick source, =o= open issues, =p= by project, =f= filter, =v= view, =q= saved query, =S= sync saved query to Linear, =P= publish current source  |
| =e= ...     | edit: =d= description, =s= state, =a= assignee, =l= labels, =c= comment         |
| =c= ...     | create: =t= issue, =c= comment                                          |
| =k= ...     | delete: =t= issue, =c= comment, =q= saved query                          |
| =o= ...     | open: =i= issue in browser, =v= view in Linear                           |
| =y= ...     | copy: =u= issue URL                                                      |

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=.

*** Sources

A *source* is anything Pearl can fetch from: a Linear favorite, a Custom View, a project / cycle / label / user, a local saved query, 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 saved queries, each tagged by kind:

#+begin_example
  [view] Active sprint bugs
  [project] Orchestration Dashboard
  [user] Vrezh Mikayelyan
  [label] security
  [saved] My open work
  [saved → Eng] Active sprint bugs (synced)
  [saved → 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 saved query you've synced up to Linear (see [[*Publishing a saved query as a Linear view]] below) renders as =[saved → <Team or Personal>] Name= so you can see at a glance which scope it lives in. The arrow reads "where it lives now." Picking it dispatches as a Linear view (server-side filter run via =customView(id:)=), not as a local filter -- any drift you've made to the view on Linear's side is honored. Plain =[saved]= entries are local-only and still run the authoring filter Pearl has on disk. =[saved → ?]= means the entry is synced but Pearl couldn't resolve the team id (deleted or renamed on Linear) -- the dispatch still works, the label is just flagging stale scope metadata.

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

*** Publishing a saved query as a Linear view

A local saved query is great for your own quick filters, but the team can't see it and the Linear web UI can't run it. =pearl-sync-saved-query-to-linear= (=C-; L f S=, or =S= in the transient's Fetch group) promotes a local saved query to a Linear Custom View so it's visible alongside your sidebar favorites and the rest of the team's views.

Two commands publish:

| Command                            | Binding                              | What it does                                                       |
|------------------------------------+--------------------------------------+--------------------------------------------------------------------|
| =pearl-sync-saved-query-to-linear=   | =C-; L f S=, =S= in the transient Fetch group | Pick a saved query by name and publish (or update) it on Linear    |
| =pearl-publish-current-source=       | =C-; L f P=, =U= in the transient Fetch group | Read the buffer's =#+LINEAR-SOURCE=; if it names a local saved query, publish that one |

The first time you sync a query, 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 sync, the saved-query 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-syncing the same query calls =customViewUpdate= against the stored id -- the 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-sync from Pearl, your local plist wins.

Pearl's =:sort= / =:order= on a saved query don't sync up in v1 -- Linear's =CustomView= API has no sort input, so a synced 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 sync.

=pearl-delete-saved-query= (=C-; L k q=) on a synced 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.

*** Fetching and refreshing

| Command                      | What it does                                     |
|------------------------------+--------------------------------------------------|
| =pearl-pick-source=            | Pick a Linear favorite or local saved query 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-view=               | Run a Linear Custom View server-side             |
| =pearl-run-saved-query=        | Run a named local query from =pearl-saved-queries= |
| =pearl-sync-saved-query-to-linear= | Publish (or update) a saved query as a Linear Custom View |
| =pearl-publish-current-source= | Publish the current buffer's saved query (reads =#+LINEAR-SOURCE=) |
| =pearl-delete-saved-query=     | Delete a saved query (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 assignee prompt offers =me=, a specific =member= (resolved to a user id), or =any= (no scoping). Saved queries are local Lisp data:

#+begin_src emacs-lisp
  (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

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

*** 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: 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-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-saved-queries=         | Named local issue queries                         |
| =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-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 =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.

*** Saved queries across accounts

=pearl-saved-queries= is one shared list. A query 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 query *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]].