aboutsummaryrefslogtreecommitdiff
path: root/README.org
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
commitb081d62276378b3168c92c06153fd59db0589535 (patch)
tree9be7f7d22e0c9b4a73432fe744c09bb456c671a9 /README.org
downloadpearl-b081d62276378b3168c92c06153fd59db0589535.tar.gz
pearl-b081d62276378b3168c92c06153fd59db0589535.zip
feat: pearl — manage Linear issues from org-mode
Pearl fetches Linear issues into an org file and syncs edits back. It covers list / custom views / saved queries, per-issue and bulk rendering with comments inline, conflict-aware sync of descriptions, titles, and comments, field commands for priority / state / assignee / labels, and a transient dispatch menu. The render folds to a scannable outline and nests issues under a sortable parent. Based on and inspired by Gael Blanchemain's linear-emacs.
Diffstat (limited to 'README.org')
-rw-r--r--README.org335
1 files changed, 335 insertions, 0 deletions
diff --git a/README.org b/README.org
new file mode 100644
index 0000000..633baf4
--- /dev/null
+++ b/README.org
@@ -0,0 +1,335 @@
+#+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: <uuid>
+:LINEAR-IDENTIFIER: ENG-123
+:LINEAR-URL: https://linear.app/.../ENG-123
+:LINEAR-TEAM-ID: <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: <hash of the last-fetched description markdown>
+:LINEAR-DESC-ORG-SHA256: <hash of the rendered Org body>
+:LINEAR-DESC-UPDATED-AT: <remote timestamp>
+:LINEAR-TITLE-SHA256: <hash of the rendered title>
+: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]].