* 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 - 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 → ] 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-= / =S-=). 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: :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: :LINEAR-DESC-ORG-SHA256: :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 | | =pearl-org-file-path= | Active Org output file | | =pearl-default-team-id= | Default team for issue creation | | =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. ** 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]].