;;; pearl.el --- Linear.app integration -*- lexical-binding: t; -*- ;; Copyright (C) 2025 ;; Author: Craig Jennings ;; Based on and inspired by Gael Blanchemain's linear-emacs. ;; Version: 1.0.0 ;; Package-Requires: ((emacs "27.1") (request "0.3.0") (dash "2.17.0") (s "1.12.0") (transient "0.3.0")) ;; Keywords: tools ;; URL: https://git.cjennings.net/pearl.git ;; This file is not part of GNU Emacs. ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;;; Commentary: ;; pearl integrates Linear.app issue tracking with Emacs and org-mode. ;; Fetch your issues -- open issues, a project, an ad-hoc filter, a Linear ;; Custom View, or a named local view -- into a single self-describing org ;; file: each issue is a heading, its description and comments render in the ;; body, and its structured fields live in a namespaced LINEAR-* drawer. ;; ;; Edit a description or title and push it back with conflict-aware sync; set ;; priority, state, assignee, or labels by command; add comments; and create ;; or delete issues -- all without leaving Emacs. See README.org for the full ;; command surface and configuration. ;;; Code: ;; ;; This file is organized into the following sections: ;; ;; - Dependencies and requirements ;; - Customization and variables ;; - Core API functions (async-first) ;; - Team management functions ;; - Issue management functions ;; - Issue state management functions ;; - Org-mode integration functions ;; - Mapping functions (between Linear and org-mode) ;; - User-facing commands ;; - Org-mode sync hooks ;; - Backward compatibility functions ;; ;; Dependencies (require 'request) (require 'json) (require 'dash) (require 's) (require 'org) (require 'cl-lib) (require 'transient) (require 'auth-source) ;;; Customization and Variables (defgroup pearl nil "Integration with Linear issue tracking." :group 'tools :prefix "pearl-") (defcustom pearl-api-key nil "API key for Linear.app. Can be set manually or loaded from LINEAR_API_KEY environment variable using `pearl-load-api-key-from-env'." :type 'string :group 'pearl) (defcustom pearl-graphql-url "https://api.linear.app/graphql" "GraphQL endpoint URL for Linear API." :type 'string :group 'pearl) (defcustom pearl-default-team-id nil "Default team ID to use for creating issues. When set, skips team selection prompt when creating new issues." :type 'string :group 'pearl) (defcustom pearl-debug nil "Enable debug logging for Linear requests. When enabled, detailed API request and response information will be logged to the *Messages* buffer." :type 'boolean :group 'pearl) (defcustom pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory) "Path to the org file where Linear issues are stored. This file is created or updated by `pearl-list-issues'. Defaults to \\='gtd/linear.org\\=' in your `org-directory'." :type 'file :group 'pearl) (defcustom pearl-accounts nil "Named Linear accounts for multi-workspace use. An alist of (NAME . PLIST) where NAME is a string and PLIST carries the per-workspace settings: :api-key-source how the key is *found* (not the key itself), a tagged list -- one of (:auth-source :host H :user U), (:env \"VAR\"), or (:literal \"lin_...\"). :org-file the account's active org file (`~' is expanded). :default-team-id default team for new issues; optional. :default-view local-view name `pearl-open-default-view' opens for this account, or nil for my open issues; optional. :url GraphQL endpoint; optional, defaults to `pearl-graphql-url'. Leave nil for legacy single-account behavior off the standalone `pearl-api-key' / `pearl-org-file-path' globals. See `pearl-switch-account' and `pearl-default-account'." :type '(alist :key-type string :value-type plist) :group 'pearl) (defcustom pearl-default-account nil "Name of the account `pearl-active-account' resolves to at first need. A durable preference (unlike the runtime `pearl-active-account'). When `pearl-accounts' is set and no account is active yet, the first command that needs account context resolves this name; if it is nil, that command errors asking the user to set it or run `pearl-switch-account'." :type '(choice (const :tag "None" nil) string) :group 'pearl) (defcustom pearl-async-default t "Use async API calls by default. When t, all API calls will be asynchronous unless explicitly overridden. Set to nil to use synchronous calls by default for backward compatibility." :type 'boolean :group 'pearl) (defcustom pearl-progress-messages t "Show progress messages during long operations. When enabled, displays messages about ongoing API operations." :type 'boolean :group 'pearl) (defcustom pearl-max-issue-pages 10 "Maximum number of issue pages to fetch, at 100 issues per page. `pearl-list-issues' stops after this many pages and warns that the result may be truncated. Raise it if you are assigned more issues than this cap can hold." :type 'integer :group 'pearl) (defcustom pearl-request-timeout 30 "Seconds a synchronous Linear request waits before giving up. The synchronous wrappers busy-wait for their async counterpart to call back. If it never does (dropped connection, server stall), they return nil after this many seconds rather than hanging Emacs." :type 'number :group 'pearl) (defcustom pearl-surface-buffer t "When non-nil, surface the active org buffer after a command updates it. A command run while the buffer is buried (its async result lands after you have navigated away) brings the buffer back to a window so the result is visible. Set to nil to leave window layout untouched." :type 'boolean :group 'pearl) (defcustom pearl-surface-select-window nil "When non-nil, surfacing the active buffer also selects its window. With the default nil, `pearl-surface-buffer' shows the buffer via `display-buffer' without moving focus. Set non-nil to have focus follow \(via `pop-to-buffer') so point lands in the surfaced buffer." :type 'boolean :group 'pearl) (defcustom pearl-compose-window-side 'bottom "Side of the frame Pearl's compose and conflict buffers open from. One of `bottom', `top', `left', or `right'. Pearl always applies this side window when popping a compose buffer, overriding any `display-buffer-alist' entry for those buffers." :type '(choice (const bottom) (const top) (const left) (const right)) :group 'pearl) (defcustom pearl-compose-window-size 0.3 "Size of Pearl's compose and conflict side window. A float below 1 is a fraction of the frame (0.3 = 30%); an integer is an absolute size in lines (for `bottom'/`top') or columns (for `left'/`right'). Paired with `pearl-compose-window-side'." :type 'number :group 'pearl) (defun pearl--compose-display-action () "Return the `display-buffer' action for a compose or conflict buffer. A side window on `pearl-compose-window-side', sized by `pearl-compose-window-size' -- `window-width' for the left/right sides, `window-height' for top/bottom." (cons '(display-buffer-in-side-window) (list (cons 'side pearl-compose-window-side) (cons (if (memq pearl-compose-window-side '(left right)) 'window-width 'window-height) pearl-compose-window-size)))) (defun pearl--surface-buffer (buffer) "Bring BUFFER to a window after a command updated it, unless already shown. No-op when `pearl-surface-buffer' is nil, BUFFER is dead, or BUFFER is already visible in some window (so the common already-on-screen case causes no window churn). Uses `pop-to-buffer' when `pearl-surface-select-window' is non-nil \(focus follows) and `display-buffer' otherwise (shown without stealing focus)." (when (and pearl-surface-buffer (buffer-live-p buffer) (not (get-buffer-window buffer t))) (if pearl-surface-select-window (pop-to-buffer buffer) (display-buffer buffer)))) (defcustom pearl-fold-after-update t "When non-nil, re-fold the Linear page after a fetch or refresh repopulates it. `#+STARTUP:' visibility only applies on a file's first visit, so a repopulation that replaces a visited buffer's contents in place would otherwise leave the page fully expanded. Folding restores the scannable outline -- issue headings visible, descriptions, comments, and property drawers hidden. Set to nil to leave the buffer expanded after updates." :type 'boolean :group 'pearl) (defcustom pearl-help-header t "When non-nil, render a folded \"* Pearl Help\" heading atop each Linear file. The heading collapses on open (via a `:VISIBILITY: folded' property) and carries the edit-then-save reminder, the keymap cues, and the priority-cookie legend, so the help is discoverable without a dozen lines of `#' preamble cluttering the buffer. It has no `LINEAR-ID', so save and merge ignore it. Set to nil to omit it entirely, leaving just the machine `#+' keywords." :type 'boolean :group 'pearl) (defcustom pearl-hide-preamble t "When non-nil, collapse the `#+' keyword preamble to a one-line summary. A display overlay replaces the `#+' block atop each Linear file with a styled \"Pearl - · ·