aboutsummaryrefslogtreecommitdiff
path: root/pearl.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-31 07:10:33 -0500
committerCraig Jennings <c@cjennings.net>2026-05-31 07:10:33 -0500
commit394c75b2f92e2a72390b3417e483849bed13a399 (patch)
treea5b92603ecb049381015fb7556f25dcf74fa6c21 /pearl.el
parent4767d16423ff5c87cf1222603c7b8f1b59af8ef2 (diff)
downloadpearl-394c75b2f92e2a72390b3417e483849bed13a399.tar.gz
pearl-394c75b2f92e2a72390b3417e483849bed13a399.zip
feat(accounts): multi-account support with tests
I added a named-account layer for working more than one Linear workspace from one Emacs. Before this, everything that identified a workspace was a single global: pearl-api-key, pearl-graphql-url, pearl-org-file-path, and the lookup caches. Nothing stopped a work command from running under personal credentials or a work fetch from landing in the personal file, and switching accounts meant re-customizing the key, team, and file by hand and clearing the cache. pearl-accounts maps a name to a per-workspace plist (credential source, org file, default team, optional endpoint), and pearl-switch-account makes one active. Account state flows through an explicit context rather than mutating globals. Every request snapshots its account at dispatch through pearl--graphql-request-async and re-establishes it around the callbacks. A switch mid-fetch can't bleed into a request already in flight: the result finishes into the account it was dispatched under. I centralized this in the one request primitive, so the leak surface is a single function instead of every call site. Rendered files carry a #+LINEAR-ACCOUNT marker, and a buffer guard refuses a command run from one account's file while another is active, naming both, so a work edit can't push under personal credentials. An unmarked legacy file lets reads and refreshes through and acquires its marker on the first refresh. Mutations wait until then. Credentials resolve through auth-source, an env var, or an inline literal, and a resolved key is never persisted through Customize or logged. The active account shows in the mode line. A saved query can carry an :account so it refuses to run under the wrong workspace before any lookup. With pearl-accounts unset, everything behaves exactly as before, off the legacy globals.
Diffstat (limited to 'pearl.el')
-rw-r--r--pearl.el347
1 files changed, 304 insertions, 43 deletions
diff --git a/pearl.el b/pearl.el
index 78612a1..7214214 100644
--- a/pearl.el
+++ b/pearl.el
@@ -62,6 +62,7 @@
(require 'org)
(require 'cl-lib)
(require 'transient)
+(require 'auth-source)
;;; Customization and Variables
(defgroup pearl nil
@@ -101,6 +102,34 @@ 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.
+ :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.
@@ -373,6 +402,190 @@ Backs the comment-edit permission check; clear it to force a refresh.")
(defvar pearl--active-requests 0
"Number of currently active API requests.")
+;;; Multi-Account Support
+
+(defvar pearl-active-account nil
+ "Name of the Linear account currently active, or nil.
+Runtime state, never a persisted defcustom: which account you are on right
+now. `pearl-switch-account' sets it with `setq'; persisting it would make
+the last switch stick across sessions and fight `pearl-default-account'.")
+
+(defun pearl--resolve-api-key-source (source account)
+ "Resolve SOURCE to an API key string for ACCOUNT (named for error messages).
+SOURCE is a tagged list: (:literal KEY), (:env VAR), or
+\(:auth-source :host H :user U). Errors -- naming ACCOUNT, never dumping the
+lookup -- when the key cannot be found or SOURCE is malformed."
+ (pcase source
+ (`(:literal ,key) key)
+ (`(:env ,var)
+ (or (getenv var)
+ (error "No API key for account %s: environment variable %s is unset"
+ account var)))
+ (`(:auth-source . ,plist)
+ (let* ((found (car (apply #'auth-source-search
+ (append plist '(:require (:secret) :max 1)))))
+ (secret (and found (plist-get found :secret))))
+ (cond
+ ((null secret)
+ (error "No API key for account %s: auth-source found no matching entry"
+ account))
+ ((functionp secret) (funcall secret))
+ (t secret))))
+ (_ (error "No API key for account %s: unrecognized :api-key-source form"
+ account))))
+
+(defun pearl--resolve-account (name)
+ "Resolve account NAME from `pearl-accounts' to a context plist.
+The plist is (:name :api-key :org-file :default-team-id :url) with the key
+resolved from `:api-key-source', `:org-file' expanded, and `:url' defaulting
+to `pearl-graphql-url'. Errors when NAME is unknown or its key is missing."
+ (let ((spec (cdr (assoc name pearl-accounts))))
+ (unless spec
+ (error "No Pearl account named %s" name))
+ (let ((org-file (plist-get spec :org-file)))
+ (list :name name
+ :api-key (pearl--resolve-api-key-source
+ (plist-get spec :api-key-source) name)
+ :org-file (and org-file (expand-file-name org-file))
+ :default-team-id (plist-get spec :default-team-id)
+ :url (or (plist-get spec :url) pearl-graphql-url)))))
+
+(defun pearl--current-account-context ()
+ "Return the active account's context plist, or nil in legacy single-account mode.
+Returns nil when `pearl-accounts' is empty (downstream then reads the legacy
+globals unchanged). Otherwise resolves `pearl-active-account', falling back to
+`pearl-default-account' on first need -- which it then records as active.
+Errors when accounts are configured but neither is set."
+ (when pearl-accounts
+ (let ((name (or pearl-active-account
+ pearl-default-account
+ (user-error (concat "Pearl accounts are configured but none is active; "
+ "set `pearl-default-account' or run "
+ "`pearl-switch-account'")))))
+ (unless pearl-active-account
+ (setq pearl-active-account name))
+ (pearl--resolve-account name))))
+
+(defvar pearl--account-context nil
+ "The account context bound for the current dynamic extent, or nil.
+`pearl--with-account-context' binds this so async continuations -- pagination
+and render/mutate callbacks -- re-establish the account that was active when
+the request was dispatched, rather than reading whatever account happens to be
+active when they later run.")
+
+(defmacro pearl--with-account-context (context &rest body)
+ "Evaluate BODY with the legacy globals bound to CONTEXT's account values.
+CONTEXT is a context plist from `pearl--resolve-account'. A nil CONTEXT is a
+passthrough: BODY runs against the current globals (legacy single-account
+mode), so nothing changes when `pearl-accounts' is unset. Also binds
+`pearl--account-context' so a nested async dispatch propagates the same
+account into its own callbacks."
+ (declare (indent 1) (debug (form body)))
+ (let ((ctx (make-symbol "ctx")))
+ `(let ((,ctx ,context))
+ (if (null ,ctx)
+ (progn ,@body)
+ (let ((pearl--account-context ,ctx)
+ (pearl-api-key (plist-get ,ctx :api-key))
+ (pearl-graphql-url (plist-get ,ctx :url))
+ (pearl-org-file-path (or (plist-get ,ctx :org-file)
+ pearl-org-file-path))
+ (pearl-default-team-id (plist-get ,ctx :default-team-id)))
+ ,@body)))))
+
+(defun pearl--read-buffer-account (&optional buffer)
+ "Return the `#+LINEAR-ACCOUNT' name BUFFER carries, or nil.
+BUFFER defaults to the current buffer; the search is bounded to the preamble so
+it stays cheap on large buffers, matching `pearl--read-active-source'."
+ (with-current-buffer (or buffer (current-buffer))
+ (save-excursion
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+LINEAR-ACCOUNT: \\(.*\\)$" 4000 t)
+ (string-trim (match-string 1))))))
+
+(defun pearl--require-account-context (&optional mutation)
+ "Guard a current-buffer command against the active Linear account.
+Returns the active account's context plist, or nil in legacy single-account
+mode (`pearl-accounts' unset), where there is nothing to guard.
+
+Otherwise enforces buffer ownership before any API call:
+- buffer owned by the active account -> proceed;
+- buffer owned by a *different* configured account -> refuse, naming both, so a
+ command can't run under one account against another's file;
+- no `#+LINEAR-ACCOUNT' marker and MUTATION non-nil -> refuse until a refresh
+ stamps ownership; a non-mutating render/list command proceeds (it will stamp
+ ownership on its next write)."
+ (when pearl-accounts
+ (let* ((ctx (pearl--current-account-context))
+ (active (plist-get ctx :name))
+ (buffer-account (pearl--read-buffer-account)))
+ (cond
+ ((and buffer-account (not (string-equal buffer-account active)))
+ (user-error (concat "This file belongs to %s; active account is %s. "
+ "Run M-x pearl-switch-account first")
+ buffer-account active))
+ ((and (null buffer-account) mutation)
+ (user-error "No LINEAR-ACCOUNT in this file; refresh it under an account first"))
+ (t ctx)))))
+
+(defun pearl--ensure-buffer-account-header ()
+ "Stamp `#+LINEAR-ACCOUNT' into the current buffer if missing (accounts mode).
+A no-op in legacy mode or when the marker is already present. The merge/refresh
+path doesn't rebuild the header through `pearl--build-org-content', so without
+this a legacy file (rendered before accounts were configured) would never
+acquire ownership -- leaving the buffer guard's \"refresh it under an account
+first\" advice unresolvable, since the refresh itself wouldn't stamp it."
+ (when (and pearl--account-context
+ (not (pearl--read-buffer-account)))
+ (save-excursion
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+LINEAR-SOURCE:" nil t)
+ (beginning-of-line)
+ (insert (format "#+LINEAR-ACCOUNT: %s\n"
+ (plist-get pearl--account-context :name)))))))
+
+(defun pearl--clear-account-scoped-state ()
+ "Clear every cache scoped to a single Linear workspace.
+The one place that lists the account-scoped caches -- the five Linear lookups
+plus any future account-derived state -- so a newly added cache is registered
+here and `pearl-switch-account' invalidates it without re-implementing cache
+semantics. A personal workspace's teams must never bleed into a work one."
+ (setq pearl--cache-teams nil
+ pearl--cache-states nil
+ pearl--cache-team-collections nil
+ pearl--cache-views nil
+ pearl--cache-viewer nil))
+
+(defun pearl--mode-line-lighter ()
+ "Return the `pearl-mode' mode-line lighter, naming the active account.
+\" Pearl[work]\" when accounts are configured and one is active; plain
+\" Pearl\" in legacy single-account mode."
+ (if (and pearl-accounts pearl-active-account)
+ (format " Pearl[%s]" pearl-active-account)
+ " Pearl"))
+
+(defun pearl-switch-account (name)
+ "Switch the active Linear account to NAME.
+Interactively, completes over the configured `pearl-accounts'. Resolves the
+account (erroring on an unknown name or missing key), makes it active, clears
+the account-scoped caches so the next lookup refetches against the new
+workspace, refreshes the mode-line indicator, and visits the account's org file
+when it exists."
+ (interactive
+ (list (if (null pearl-accounts)
+ (user-error "No accounts configured; set `pearl-accounts' first")
+ (completing-read "Switch to account: "
+ (mapcar #'car pearl-accounts) nil t))))
+ (let ((ctx (pearl--resolve-account name)))
+ (setq pearl-active-account name)
+ (pearl--clear-account-scoped-state)
+ (force-mode-line-update t)
+ (let ((file (plist-get ctx :org-file)))
+ (if (and file (file-exists-p file))
+ (progn (find-file file)
+ (message "Switched to account %s" name))
+ (message "Switched to account %s (no org file yet; run a fetch)" name)))))
+
;;; Core API Functions (Async-First Architecture)
(defun pearl--headers ()
@@ -428,38 +641,49 @@ If SUCCESS-FN or ERROR-FN are not provided, default handlers will be used."
(message "Linear API error: %s" error-thrown)
(pearl--log "Error response: %s" (prin1-to-string data)))))
- (let ((request-data (json-encode `(("query" . ,query)
- ,@(when variables `(("variables" . ,variables))))))
- ;; Build headers before bumping the counter: `pearl--headers' signals
- ;; when the API key is unset, and that must happen before the increment
- ;; or no callback ever runs to decrement it (leaking the count).
- (headers (pearl--headers)))
- (pearl--log "Request payload: %s" request-data)
- (setq pearl--active-requests (1+ pearl--active-requests))
-
- (request
- pearl-graphql-url
- :type "POST"
- :headers headers
- :data request-data
- :parser 'json-read
- :success (cl-function
- (lambda (&key data &allow-other-keys)
- (setq pearl--active-requests (1- pearl--active-requests))
- (pearl--log "Response received: %s" (prin1-to-string data))
- (funcall success-fn data)))
- :error (cl-function
- (lambda (&key error-thrown response data &allow-other-keys)
- (setq pearl--active-requests (1- pearl--active-requests))
- (pearl--log "Error: %s" error-thrown)
- ;; Guard the status-code read: `response' can be nil on some
- ;; transport failures, and the accessor errors on a non-struct.
- (when (request-response-p response)
- (pearl--log "Response status: %s"
- (request-response-status-code response)))
- (when data
- (pearl--log "Error response: %s" (prin1-to-string data)))
- (funcall error-fn error-thrown response data))))))
+ ;; Snapshot the account context once, at dispatch. Reuse the context already
+ ;; bound by an enclosing `pearl--with-account-context' (pagination, a wrapping
+ ;; command); otherwise resolve the active account now. The macro binds the
+ ;; legacy globals for the synchronous header build and is re-established around
+ ;; each callback, so a mid-flight `pearl-switch-account' can't bleed into a
+ ;; request that was already dispatched. In legacy mode (no `pearl-accounts')
+ ;; the context is nil and the macro is a passthrough.
+ (let ((ctx (or pearl--account-context (pearl--current-account-context))))
+ (pearl--with-account-context ctx
+ (let ((request-data (json-encode `(("query" . ,query)
+ ,@(when variables `(("variables" . ,variables))))))
+ ;; Build headers before bumping the counter: `pearl--headers' signals
+ ;; when the API key is unset, and that must happen before the increment
+ ;; or no callback ever runs to decrement it (leaking the count).
+ (headers (pearl--headers)))
+ (pearl--log "Request payload: %s" request-data)
+ (setq pearl--active-requests (1+ pearl--active-requests))
+
+ (request
+ pearl-graphql-url
+ :type "POST"
+ :headers headers
+ :data request-data
+ :parser 'json-read
+ :success (cl-function
+ (lambda (&key data &allow-other-keys)
+ (setq pearl--active-requests (1- pearl--active-requests))
+ (pearl--log "Response received: %s" (prin1-to-string data))
+ (pearl--with-account-context ctx
+ (funcall success-fn data))))
+ :error (cl-function
+ (lambda (&key error-thrown response data &allow-other-keys)
+ (setq pearl--active-requests (1- pearl--active-requests))
+ (pearl--log "Error: %s" error-thrown)
+ ;; Guard the status-code read: `response' can be nil on some
+ ;; transport failures, and the accessor errors on a non-struct.
+ (when (request-response-p response)
+ (pearl--log "Response status: %s"
+ (request-response-status-code response)))
+ (when data
+ (pearl--log "Error response: %s" (prin1-to-string data)))
+ (pearl--with-account-context ctx
+ (funcall error-fn error-thrown response data)))))))))
(defun pearl--graphql-request (query &optional variables)
"Synchronous wrapper for GraphQL requests (backward compatibility).
@@ -1620,11 +1844,7 @@ none, returns nil. FORCE refreshes the collection cache first."
"Clear the Linear lookup caches (teams, states, per-team collections, views).
Use after renaming things in Linear, or to force the next lookup to refetch."
(interactive)
- (setq pearl--cache-teams nil
- pearl--cache-states nil
- pearl--cache-team-collections nil
- pearl--cache-views nil
- pearl--cache-viewer nil)
+ (pearl--clear-account-scoped-state)
(message "Linear caches cleared"))
(defun pearl-update-issue-state (issue-id state-name team-id)
@@ -2677,6 +2897,7 @@ unpushed local edits, they are stashed first (kill ring + the conflict-backup
buffer) so the refresh can't silently lose them (decision 4), then the refresh
proceeds and the subtree is replaced with Linear's version."
(interactive)
+ (pearl--require-account-context)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
@@ -2822,6 +3043,7 @@ comment instead of the one-line minibuffer. BODY, when supplied
non-interactively, is sent as-is. Works from anywhere inside an issue subtree;
the new comment is the viewer's own, so it renders editable."
(interactive (list nil))
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
@@ -2843,6 +3065,7 @@ the issue body and syncs to Linear through the usual conflict gate, C-c C-k
cancels. An alternative to editing the description inline for anyone who wants
a dedicated buffer. Works from anywhere inside an issue subtree."
(interactive)
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let ((issue-id (org-entry-get nil "LINEAR-ID"))
@@ -2909,6 +3132,7 @@ Works from anywhere inside an issue subtree. Confirms first, then issues a
soft delete (Linear moves the issue to Trash, recoverable for about 30 days);
on success the issue's Org subtree is removed from the buffer."
(interactive)
+ (pearl--require-account-context t)
(let* ((marker (save-excursion (pearl--goto-issue-heading-or-error) (point-marker)))
(issue-id (org-entry-get marker "LINEAR-ID"))
(ident (or (org-entry-get marker "LINEAR-IDENTIFIER") "this issue")))
@@ -2999,6 +3223,7 @@ including one a TODO-keyword cycle can't express."
(mapcar (lambda (s) (cdr (assoc 'name s)))
(and team-id (pearl--team-states team-id)))
nil t))))
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let* ((team-id (org-entry-get nil "LINEAR-TEAM-ID"))
@@ -3030,6 +3255,7 @@ inside an issue subtree."
(list (completing-read "Assignee: "
(pearl--team-collection-names 'members team-id)
nil t))))
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let* ((team-id (org-entry-get nil "LINEAR-TEAM-ID"))
@@ -3066,6 +3292,7 @@ inside an issue subtree."
(list (completing-read-multiple
"Labels (comma-separated, empty to clear): "
(pearl--team-collection-names 'labels team-id)))))
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let* ((team-id (org-entry-get nil "LINEAR-TEAM-ID"))
@@ -3157,13 +3384,16 @@ only add the ones a failed team fetch left out."
issues))))
(append team-states issue-states))))
-(defun pearl--build-org-content (issues &optional source truncated states)
+(defun pearl--build-org-content (issues &optional source truncated states account)
"Build the Org content string for the linear org file from ISSUES.
SOURCE is the active-source descriptor recorded in the header so a later
`pearl-refresh-current-view' can re-run it; TRUNCATED marks that the page cap
was hit. STATES is the ordered workflow-state list (see
`pearl--gather-header-states') the `#+TODO:' line is derived from; a nil STATES
-yields the hardcoded default. Pure function, no side effects."
+yields the hardcoded default. ACCOUNT, when non-nil, is the Linear account name
+stamped as `#+LINEAR-ACCOUNT' so the buffer guard can tell which workspace owns
+this file; nil (legacy single-account mode) omits the marker. Pure function,
+no side effects."
(let* ((src (or source '(:type filter :name "Linear issues" :filter nil)))
(name (pearl--source-name src))
(filter (plist-get src :filter)))
@@ -3175,6 +3405,8 @@ yields the hardcoded default. Pure function, no side effects."
(insert (format "#+TODO: %s\n" (pearl--derive-todo-line states)))
;; Source-tracking metadata: the serialized source drives refresh; the
;; rest is human-readable provenance.
+ (when account
+ (insert (format "#+LINEAR-ACCOUNT: %s\n" account)))
(insert (format "#+LINEAR-SOURCE: %s\n" (prin1-to-string src)))
(insert (format "#+LINEAR-RUN-AT: %s\n" (format-time-string "%Y-%m-%d %H:%M")))
(insert (format "#+LINEAR-FILTER: %s\n" (pearl--summarize-filter filter)))
@@ -3227,8 +3459,10 @@ In every case the resulting buffer is surfaced (see `pearl--surface-buffer'),
so a fetch run from a menu or dashboard actually shows the issues instead of
just writing the file and leaving it off-screen."
(let* ((org-file-path pearl-org-file-path)
+ (account (and pearl--account-context
+ (plist-get pearl--account-context :name)))
(states (pearl--gather-header-states issues))
- (new-content (pearl--build-org-content issues source truncated states))
+ (new-content (pearl--build-org-content issues source truncated states account))
(existing-buf (find-buffer-visiting org-file-path)))
(cond
;; Branch A: no buffer visits the file -- atomic file write, then visit.
@@ -3429,6 +3663,16 @@ active file with the query recorded as the source."
(order (plist-get spec :order))
(source (list :type 'filter :name name :filter filter-plist
:sort sort :order order)))
+ ;; Account guard, checked before any filter compilation or network call:
+ ;; a query tagged for another account would otherwise resolve its team /
+ ;; state / label names against the wrong workspace. Inert in legacy mode
+ ;; and for an untagged (shared) query.
+ (let ((q-account (plist-get spec :account)))
+ (when (and pearl-accounts q-account)
+ (let ((active (plist-get (pearl--current-account-context) :name)))
+ (unless (string-equal q-account active)
+ (user-error "Saved query '%s' belongs to %s; switch accounts first"
+ name q-account)))))
;; Validate the saved query's authoring plist at the command boundary so
;; a typo'd key or bad value surfaces as a clear user-error instead of
;; being silently dropped or failing deeper in the compiler.
@@ -3988,6 +4232,7 @@ the user can re-sync with Replace to reconcile."
nil t))))
(when (pearl--filter-sentinel-value-p name)
(user-error "Cancelled; no saved query synced"))
+ (pearl--require-account-context t)
(let ((entry (assoc name pearl-saved-queries)))
(unless entry
(user-error "No saved query named %s" name))
@@ -4119,6 +4364,7 @@ name to sync under), or no local saved query matches the recorded name.
Bound under \\='C-; L f P\\=' inside `pearl-mode' buffers; reachable
through the transient as the `U' (upload) entry in the Fetch group."
(interactive)
+ (pearl--require-account-context t)
(let* ((source (pearl--read-active-source))
(type (and source (plist-get source :type)))
(name (and source (plist-get source :name))))
@@ -5024,6 +5270,7 @@ once, and only if comments are dirty; then each dirty field is saved
sequentially through its conflict gate and a single summary is reported. A
clean issue does no network at all."
(interactive)
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-issue-heading-or-error)
(let* ((marker (point-marker))
@@ -5154,6 +5401,7 @@ counts; declining mutates nothing. On confirm the dirty fields save
sequentially, continuing past any per-field conflict, and one summary is
reported. A clean file does nothing."
(interactive)
+ (pearl--require-account-context t)
(let* ((buffer (current-buffer))
(scan (pearl--scan-all-dirty)))
(if (null scan)
@@ -5246,7 +5494,10 @@ edit-then-merge flow keeps the user's expanded subtrees expanded."
"Refresh the active file's run-at, count, and truncation header lines.
ISSUE-COUNT is the new issue total; TRUNCATED marks a page-cap hit. The
`#+LINEAR-SOURCE:' descriptor is left untouched -- only the human-readable
-provenance advances on a refresh."
+provenance advances on a refresh. Under accounts mode an unmarked file also
+acquires its `#+LINEAR-ACCOUNT' stamp here (see
+`pearl--ensure-buffer-account-header')."
+ (pearl--ensure-buffer-account-header)
(save-excursion
(goto-char (point-min))
(when (re-search-forward "^#\\+LINEAR-RUN-AT: .*$" nil t)
@@ -5341,6 +5592,7 @@ are dropped, while any subtree with unpushed edits is kept. This is the
same-source counterpart to the replace-on-switch behavior of the query and view
commands. Errors if no source is recorded."
(interactive)
+ (pearl--require-account-context)
(let ((source (pearl--read-active-source))
(buffer (current-buffer)))
(unless source
@@ -5628,8 +5880,15 @@ resolves to a single label."
;;;###autoload
(defun pearl-load-api-key-from-env ()
- "Try to load Linear API key from environment variable."
+ "Try to load Linear API key from environment variable.
+Legacy single-account path only: once `pearl-accounts' is configured this
+refuses, so it can't bypass the per-account resolver and overwrite the active
+account's key in memory. Use `:api-key-source (:env \"...\")' in
+`pearl-accounts' instead."
(interactive)
+ (when pearl-accounts
+ (user-error
+ "Accounts are configured; put :api-key-source (:env \"...\") in `pearl-accounts'"))
(let ((env-key (getenv "LINEAR_API_KEY")))
(if env-key
(progn
@@ -5784,6 +6043,7 @@ description sync -- unchanged since fetch sends nothing, a local edit against
an unchanged remote pushes, and a both-sides-changed case is refused and
reported (refresh to reconcile)."
(interactive)
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-heading-or-error "Not on a Linear comment")
(let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID"))
@@ -5814,6 +6074,7 @@ with no stored hash, gets a stronger discard-local-edits confirmation. On
success the comment's Org subtree is removed; sibling comments and the issue
body stay."
(interactive)
+ (pearl--require-account-context t)
(save-excursion
(pearl--goto-heading-or-error "Not on a Linear comment")
(let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID"))
@@ -6035,7 +6296,7 @@ Binds `pearl-prefix-map' under `pearl-keymap-prefix' so every Pearl command is
reachable from the keyboard. Turns on automatically in any buffer Pearl
rendered (one carrying a `#+LINEAR-SOURCE' header); see
`pearl--maybe-enable-mode' on `org-mode-hook'."
- :lighter " Pearl"
+ :lighter (:eval (pearl--mode-line-lighter))
:keymap pearl-mode-map)
(defun pearl--buffer-is-pearl-p ()