#+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: :LINEAR-IDENTIFIER: ENG-123 :LINEAR-URL: https://linear.app/.../ENG-123 :LINEAR-TEAM-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: :LINEAR-DESC-ORG-SHA256: :LINEAR-DESC-UPDATED-AT: :LINEAR-TITLE-SHA256: :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]].