diff options
Diffstat (limited to 'README.org')
| -rw-r--r-- | README.org | 335 |
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]]. |
