aboutsummaryrefslogtreecommitdiff

PEARL Edits and Reflects Linear

Features | Installation | Quick Start | Commands | Configuration | Development & Testing | FAQ | History | License

" 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

  • 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
  • Well-tested with isolated ERT files, request fixtures, and coverage support

Installation

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+)

(unless (package-installed-p 'pearl)
  (package-vc-install "https://git.cjennings.net/pearl.git"))

use-package with :vc (Emacs 29+)

(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))

Straight

(straight-use-package
 '(pearl :type git :repo "https://git.cjennings.net/pearl.git"))

Doom Emacs

In packages.el:

(package! pearl
  :recipe (:type git :repo "https://git.cjennings.net/pearl.git" :files ("*.el")))

In config.el:

(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))

Manual

git clone https://git.cjennings.net/pearl.git
(add-to-list 'load-path "/path/to/pearl")
(require 'pearl)

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

Quick Start

  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:

    export LINEAR_API_KEY=lin_api_...

    Then load it from Emacs:

    (pearl-load-api-key-from-env)

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

    machine api.linear.app login apikey password YOUR_API_KEY
    
    (setq pearl-api-key
          (auth-source-pick-first-password :host "api.linear.app"))
  3. Choose the Org file Pearl owns:

    (setq pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory))
  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

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:

(global-set-key (kbd "C-c L") #'pearl-menu)

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:

(setq pearl-keymap-prefix "C-c l")   ; or nil to bind nothing

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
@ mention a Linear user (@displayName) at point
m open the full transient menu
f fetch (issue sources): s pick source, o open issues, p by project, f filter, i open issue by
id
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, s sort, g group, r reverse order
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:

(global-set-key (kbd "C-; L") pearl-prefix-map)

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 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:

[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)

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. An issue favorite renders that one issue in the buffer as its own source – its full subtree (description, comments, drawer), the same as every other source. The remaining non-list favorites (document, dashboard, …) open in the browser instead. Favorites are picker entries, never persisted – a chosen favorite resolves to a concrete 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.

To open a single issue you don't have favorited, use pearl-open-issue-by-id (C-; L f i, or i in the transient). Type an identifier like ENG-123 (or a raw issue id) and Pearl fetches and renders that one issue. Handy when someone hands you an issue id and you'd rather have it in Pearl than a browser tab.

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 Pick a local view by name and publish (or
group update) it on Linear
pearl-publish-current-view C-; L v U, U in the transient Views Read the buffer's #+LINEAR-SOURCE; if
group 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:

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

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-open-issue-by-id Open one issue by its identifier (ENG-123) or id, rendered in-buffer
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

Running a Linear Custom View (via pearl-run-linear-view or by picking a favorited view in pearl-pick-source) mirrors the view as Linear shows it: its own filter runs server-side, its configured sort order is reproduced, and its "completed issues" display setting is honored. A view set to hide completed issues shows only open issues in Pearl too; a view set to show completed issues from the past day/week/month/quarter/year shows open issues plus those completed or canceled within that window (the windows are fixed-day approximations of Linear's). Refreshing the view recomputes the window relative to the refresh time.

A view that groups its issues (by status, project, assignee, priority, or cycle) renders each group as an Org section: the issues sit one level deeper under a group heading, and the buffer opens unfolded to the issue level. The group headings are local structure (no LINEAR-ID), so editing and saving an issue is unaffected and a refresh keeps issues under the right group. Grouping by label (an issue can carry several) and sub-grouping aren't rendered yet – those views fall back to a flat list.

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:

(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)))

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.

Sorting the current view

pearl-set-sort reorders the active view without hand-editing a source. It asks for a key (updated, created, priority, title) and an order (desc / asc). pearl-toggle-sort-order flips the order on the current sort, defaulting to updated descending when nothing is set yet.

Priority and title sort the fetched issues in place: whole subtrees move by id, so unsaved edits survive, and the order sticks across a refresh. Updated and created refetch with the new server ordering. A Custom View has no server-side order in Linear's API, so updated / created are refused on a view – sort it by priority or title instead, which still reorders the issues it returned. Sorting a grouped view in place isn't supported yet. pearl-set-sort is on C-; L v s and pearl-toggle-sort-order on C-; L v r, in the menu's Views group under 5 and R, and from M-x.

Grouping the current view

pearl-set-grouping sections the active view on demand, without setting up grouping in Linear and re-fetching. It asks for a dimension: status, project, assignee, priority, or none to ungroup. The regroup is client-side over the issues already in the buffer, so it's instant and works offline. Each issue's group comes from its drawer, and an issue with no value for the dimension lands in a "No status" / "No project" / "No assignee" bucket. The choice sticks across a refresh.

Like sorting, the regroup moves whole subtrees so unsaved edits survive. Only the heading levels shift, since a grouped issue sits one level deeper under its group heading. On a Custom View that already carries its own grouping from Linear, your choice takes over for display, and none restores the view's configured grouping on the next refresh. cycle isn't offered yet: it isn't stored in the issue drawer, so an offline regroup can't read it. pearl-set-grouping is on C-; L v g, in the menu's Views group under G, and from M-x.

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-fetch-all-comments Load the issue's full comment thread (C-; L f c)
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.
  • Statepearl-edit-state, completing over the team's workflow states.
  • Assignee and labelspearl-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. The assignee renders as a leading @-tag (:@eric:bug:backend:) so you can see who owns an issue at a glance on a team or "by person" view; set pearl-show-assignee to nil to omit it. It is display-only – the :LINEAR-ASSIGNEE-ID: drawer is the source of truth, and the tag stays outside the title hash, so it never affects sync.
  • Comments – edit your own in place, or pearl-create-comment for a new one. A bulk list or view shows only the newest few comments per issue, marked N/M+ when there are more. pearl-fetch-all-comments (C-; L f c) pulls the whole thread for the issue at point and replaces its Comments subtree with the full set. It refuses if you have unsaved comment edits, so save or discard those first.
  • Mentions – in a comment or description compose buffer, type @ at the start of a word to pop a picker over the team's members and insert @displayName (Linear's mention handle), so you never have to recall a teammate's exact spelling. C-; L @ (pearl-mention-user) runs the same picker explicitly, and works inline in an issue buffer too. An @ mid-word (an email, say) stays literal, and cancelling the picker leaves a literal @.

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 TODOIN-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

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:

#+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.

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.

Pearl overlays a glyph on each heading's leading stars: a ticket (🎫) on an issue, a left speech bubble (🗨️) on each comment, and a speech balloon (💬) on the Comments container heading. The glyphs are display-only — they never enter the buffer text or interfere with sync. Turn them all off with pearl-show-glyphs, or change any one with pearl-ticket-glyph, pearl-comment-glyph, and pearl-comments-header-glyph (set a glyph to the empty string to drop just that one).

Fidelity to Linear

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

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-show-glyphs Master switch for heading glyphs (on)
pearl-ticket-glyph Glyph on issue headings (🎫)
pearl-comment-glyph Glyph on each comment heading (🗨️)
pearl-comments-header-glyph Glyph on the Comments container heading (💬)
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*

The team default is optional. Leave pearl-default-team-id unset and Pearl asks which team only when you run pearl-create-issue or pearl-list-issues-by-project. Nothing else needs it. Set it to skip that prompt.

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.

Faces and theming

Pearl defines six faces, all in the pearl customize group (M-x customize-group RET pearl). Each inherits a sensible default from a standard or diff-mode face, so they look right out of the box and you only touch them if you want to. Override any of them via Customize or your theme.

Face Where it shows Default
pearl-preamble-summary The one-line "Pearl - …" summary that inherits org-document-title, goldenrod
hides the #+ header block foreground
pearl-modified-highlight An issue field with unsaved, pushable inherits diff-changed
local edits
pearl-modified-local A locally-edited comment that can't be inherits secondary-selection
pushed (someone else's / a bot's)
pearl-modified-unknown A changed comment whose ownership isn't inherits pearl-modified-local
resolved yet
pearl-editable-comment Comment headings you can edit inherits success (green)
pearl-readonly-comment Comments you can't edit, dimmed to read as grey, darker than shadow
disabled

Everything else Pearl renders — issue headings, the LINEAR-* drawers, priority cookies, assignee @-tags, label tags, the help header, group section headings — uses standard Org faces (org-level-N, org-todo, org-priority, org-tag, org-drawer). Theme those through your Org theme; Pearl adds no separate hook for them.

Multiple accounts

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:

(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")

: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:

machine api.linear.app login work password lin_api_xxxxxxxx

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:

("My work bugs" :account "work"
                :filter (:team "ENG" :label "bug" :assignee :me))

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

Clone the repo and install the Eask-managed dependencies:

git clone https://git.cjennings.net/pearl.git
cd pearl
make setup

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 TESTING.org for the full test guide, naming conventions, fixture helpers, and coverage notes.

FAQ

I edited the heading title and the description. Why did only one of them update?

pearl-save-issue (s) saves every changed field of the issue at point in one pass, title and description together; pearl-save-all (S) does the same across the whole file. If only one field updated, you likely pushed it with a lower-level command (pearl-sync-current-issue for the body, pearl-sync-current-issue-title for the title). Reach for pearl-save-issue and edit-then-save handles every field at once.

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

  • 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

Pearl is based on and inspired by Gael Blanchemain's 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

GPL-3.0-or-later. See LICENSE.