aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.org57
-rw-r--r--docs/multi-account-spec.org4
-rw-r--r--pearl.el347
-rw-r--r--tests/test-pearl-accounts.el481
4 files changed, 843 insertions, 46 deletions
diff --git a/README.org b/README.org
index 9b3af9a..cc283f2 100644
--- a/README.org
+++ b/README.org
@@ -22,6 +22,7 @@ Pearl (backronym: *Pearl Edits and Reflects Linear*) brings Linear issues into E
- 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
- [[file:TESTING.org][Well-tested]] with isolated ERT files, request fixtures, and coverage support
@@ -362,9 +363,11 @@ Most users only need an API key and an output path. The rest are knobs for teams
| Variable | Purpose |
|-----------------------------+---------------------------------------------------|
-| =pearl-api-key= | Linear API key |
+| =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-saved-queries= | Named local issue queries |
| =pearl-max-issue-pages= | Pagination cap, 100 issues per page |
| =pearl-request-timeout= | Synchronous request timeout in seconds |
@@ -391,6 +394,58 @@ Common shapes:
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.
+** Multiple accounts
+:PROPERTIES:
+:CUSTOM_ID: multiple-accounts
+:END:
+
+If you work more than one Linear workspace — say a work account and a personal one — set =pearl-accounts= and switch between them with =M-x pearl-switch-account=. Each account names where its key is found, which Org file holds its issues, and (optionally) a default team:
+
+#+begin_src elisp
+(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")
+#+end_src
+
+=: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:
+
+#+begin_example
+machine api.linear.app login work password lin_api_xxxxxxxx
+#+end_example
+
+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.
+
+*** Saved queries across accounts
+
+=pearl-saved-queries= is one shared list. A query entry may carry an optional =:account= so it only runs under that account:
+
+#+begin_src elisp
+("My work bugs" :account "work"
+ :filter (:team "ENG" :label "bug" :assignee :me))
+#+end_src
+
+Running it under a different active account refuses before any lookup or fetch. A query *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
:PROPERTIES:
:CUSTOM_ID: development--testing
diff --git a/docs/multi-account-spec.org b/docs/multi-account-spec.org
index bf85c29..f60adb1 100644
--- a/docs/multi-account-spec.org
+++ b/docs/multi-account-spec.org
@@ -62,7 +62,7 @@ Rather than mutating globals and hoping downstream is oblivious, account state f
Every API operation *snapshots* its account context at dispatch time. The snapshot is the =pearl--resolve-account= plist captured before the request fires. Render and mutate callbacks act against the *snapshot's* org file / key, never the current global.
-*v1 decision (confirm):* if the active account changed before a callback completes, the callback still finishes into its snapshot — writing the work result to the work file, with a status message naming the account ("Updated work: 24 issues"). It does *not* write to whatever file is now current, and it does *not* silently drop the result the user asked for. (The reviewer offered finish-into-snapshot or drop-stale; finish-into-snapshot is chosen because the fetch was an explicit user request whose data belongs to its own account's file regardless of a later switch, and round 2 endorsed it as the right default.)
+*v1 decision (final):* if the active account changed before a callback completes, the callback still finishes into its snapshot — writing the work result to the work file, with a status message naming the account ("Updated work: 24 issues"). It does *not* write to whatever file is now current, and it does *not* silently drop the result the user asked for. (The reviewer offered finish-into-snapshot or drop-stale; finish-into-snapshot is chosen because the fetch was an explicit user request whose data belongs to its own account's file regardless of a later switch, and round 2 endorsed it as the right default.)
*Implementation boundary (where the snapshot is captured and applied).* So a single missed render path can't reintroduce the leak:
@@ -186,7 +186,7 @@ Passive. With =pearl-accounts= unset, the package behaves exactly as today off t
- *MP2 (saved queries) — modified to a choice.* The reviewer offered account-scoped lists, an optional =:account= guard, or document-and-warn. Chose the optional =:account= guard plus a shared default and a README warning: it keeps the v1 saved-query store unchanged (one list) while giving a refusal path for queries that must not cross accounts, where full account-scoping would add a persistence/UX layer not yet justified for two workspaces.
- *MP3 (cache clearing) — modified the test framing.* Accepted the helper (=pearl--clear-account-scoped-state= as the single source of truth, listing every account-scoped cache including future derived state). Softened the reviewer's "a test must fail when a new account-scoped cache is added without invalidation" — you can't test for an unwritten future cache directly. Instead the helper centralizes the list so a new cache is registered in one place, and a test asserts each cache the helper names is nil after a switch.
-Also, the reviewer's three open questions and the HP1 finish-vs-drop choice are baked as proposed v1 decisions (finish-into-snapshot, refuse-on-mismatch, =:account= guard) and surfaced under Open Questions for Craig's confirmation rather than left unresolved. Everything else accepted as written.
+Also, the reviewer's three open questions and the HP1 finish-vs-drop choice are baked as v1 decisions (finish-into-snapshot, refuse-on-mismatch, =:account= guard) and adopted as final — see Resolved safety decisions. Everything else accepted as written.
*Round 2 (Codex, 2026-05-25).* Rubric moved to =Ready with caveats=. All findings accepted as written, no modifications or rejects: HP1 (the implementation-boundary rule — entry resolves context, callbacks run inside the context macro, shared render fns take an explicit target), HP2 (the precise unmarked-buffer rule — render/list proceed, mutations refuse until a refresh stamps ownership), HP3 (=pearl-active-account= is a runtime =defvar=, not a persisted defcustom — a genuine correction to the round-1 draft), MP1 (=pearl-load-api-key-from-env= legacy-only, refuses under accounts), MP2 (=:account= lives in the saved-query entry and is checked before filter compilation, with an example), MP3 (mode-line lighter is the v1 indicator, header-line optional), plus the UX refusal-message-names-both-accounts and the added tests. The remaining caveats are the safety calls, now endorsed by the reviewer.
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 ()
diff --git a/tests/test-pearl-accounts.el b/tests/test-pearl-accounts.el
new file mode 100644
index 0000000..c0514a8
--- /dev/null
+++ b/tests/test-pearl-accounts.el
@@ -0,0 +1,481 @@
+;;; test-pearl-accounts.el --- Tests for pearl multi-account support -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; 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 <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Unit tests for the multi-account layer: the `:api-key-source' credential
+;; resolver, `pearl--resolve-account', and `pearl--current-account-context'.
+;; Live auth-source and the environment are stubbed so the tests need no real
+;; credentials and never touch the user's `~/.authinfo.gpg'.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; :api-key-source resolver
+
+(ert-deftest test-pearl-resolve-api-key-source-literal ()
+ "A `:literal' source returns the embedded key verbatim."
+ (should (string-equal "lin_lit_123"
+ (pearl--resolve-api-key-source '(:literal "lin_lit_123") "work"))))
+
+(ert-deftest test-pearl-resolve-api-key-source-env-present ()
+ "An `:env' source reads the named environment variable."
+ (let ((process-environment (cons "PEARL_TEST_KEY=lin_env_abc" process-environment)))
+ (should (string-equal "lin_env_abc"
+ (pearl--resolve-api-key-source '(:env "PEARL_TEST_KEY") "work")))))
+
+(ert-deftest test-pearl-resolve-api-key-source-env-missing-errors-names-account ()
+ "An unset `:env' variable errors, and the message names the account."
+ (let ((process-environment (list "PEARL_UNRELATED=1"))) ; var deliberately absent
+ (let ((err (should-error
+ (pearl--resolve-api-key-source '(:env "PEARL_NOPE_VAR") "work")
+ :type 'error)))
+ (should (string-match-p "work" (error-message-string err))))))
+
+(ert-deftest test-pearl-resolve-api-key-source-auth-source ()
+ "An `:auth-source' source returns the secret auth-source yields."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) (list (list :secret "lin_authinfo_xyz")))))
+ (should (string-equal "lin_authinfo_xyz"
+ (pearl--resolve-api-key-source
+ '(:auth-source :host "api.linear.app" :user "work") "work")))))
+
+(ert-deftest test-pearl-resolve-api-key-source-auth-source-secret-function ()
+ "auth-source may hand back the secret as a thunk; the resolver funcalls it."
+ (cl-letf (((symbol-function 'auth-source-search)
+ (lambda (&rest _) (list (list :secret (lambda () "lin_thunk_secret"))))))
+ (should (string-equal "lin_thunk_secret"
+ (pearl--resolve-api-key-source
+ '(:auth-source :host "api.linear.app" :user "work") "work")))))
+
+(ert-deftest test-pearl-resolve-api-key-source-auth-source-missing-errors-names-account ()
+ "auth-source finding nothing errors with the account name, not lookup internals."
+ (cl-letf (((symbol-function 'auth-source-search) (lambda (&rest _) nil)))
+ (let ((err (should-error
+ (pearl--resolve-api-key-source
+ '(:auth-source :host "api.linear.app" :user "personal") "personal")
+ :type 'error)))
+ (should (string-match-p "personal" (error-message-string err))))))
+
+(ert-deftest test-pearl-resolve-api-key-source-unknown-form-errors ()
+ "An unrecognized source tag errors rather than silently yielding nil."
+ (should-error (pearl--resolve-api-key-source '(:bogus "x") "work") :type 'error))
+
+;;; pearl--resolve-account
+
+(ert-deftest test-pearl-resolve-account-returns-full-context ()
+ "A named account resolves to a context plist with every field populated."
+ (let ((pearl-accounts
+ '(("work" :api-key-source (:literal "lin_work_key")
+ :org-file "/tmp/work-linear.org"
+ :default-team-id "TEAM_WORK"
+ :url "https://example.test/graphql"))))
+ (let ((ctx (pearl--resolve-account "work")))
+ (should (string-equal "work" (plist-get ctx :name)))
+ (should (string-equal "lin_work_key" (plist-get ctx :api-key)))
+ (should (string-equal "/tmp/work-linear.org" (plist-get ctx :org-file)))
+ (should (string-equal "TEAM_WORK" (plist-get ctx :default-team-id)))
+ (should (string-equal "https://example.test/graphql" (plist-get ctx :url))))))
+
+(ert-deftest test-pearl-resolve-account-expands-org-file ()
+ "A `~'-relative `:org-file' is expanded to an absolute path in the context."
+ (let ((pearl-accounts
+ '(("home" :api-key-source (:literal "k") :org-file "~/pearl-home.org"))))
+ (should (string-equal (expand-file-name "~/pearl-home.org")
+ (plist-get (pearl--resolve-account "home") :org-file)))))
+
+(ert-deftest test-pearl-resolve-account-url-defaults-to-graphql-endpoint ()
+ "An account without `:url' inherits the package's default Linear endpoint."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "k") :org-file "/tmp/w.org")))
+ (pearl-graphql-url "https://api.linear.app/graphql"))
+ (should (string-equal "https://api.linear.app/graphql"
+ (plist-get (pearl--resolve-account "work") :url)))))
+
+(ert-deftest test-pearl-resolve-account-unknown-name-errors ()
+ "Resolving a name absent from `pearl-accounts' errors."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "k") :org-file "/tmp/w.org"))))
+ (should-error (pearl--resolve-account "ghost") :type 'error)))
+
+(ert-deftest test-pearl-resolve-account-missing-key-errors-names-account ()
+ "A bad credential source surfaces as an account-named error from resolve."
+ (let ((pearl-accounts
+ '(("work" :api-key-source (:auth-source :host "api.linear.app" :user "work")
+ :org-file "/tmp/w.org"))))
+ (cl-letf (((symbol-function 'auth-source-search) (lambda (&rest _) nil)))
+ (let ((err (should-error (pearl--resolve-account "work") :type 'error)))
+ (should (string-match-p "work" (error-message-string err)))))))
+
+(ert-deftest test-pearl-resolve-account-does-not-mutate-global-key ()
+ "Resolving an account returns its key in the context, never `setq's the global."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "lin_work") :org-file "/tmp/w.org")))
+ (pearl-api-key "lin_legacy_global"))
+ (pearl--resolve-account "work")
+ (should (string-equal "lin_legacy_global" pearl-api-key))))
+
+;;; pearl--current-account-context
+
+(ert-deftest test-pearl-current-account-context-accounts-nil-returns-nil ()
+ "With no accounts configured the context is nil — legacy single-account mode."
+ (let ((pearl-accounts nil)
+ (pearl-active-account nil)
+ (pearl-default-account nil))
+ (should (null (pearl--current-account-context)))))
+
+(ert-deftest test-pearl-current-account-context-resolves-default-on-first-need ()
+ "Accounts set, none active: the default is resolved and becomes active."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-default-account "home")
+ (pearl-active-account nil))
+ (let ((ctx (pearl--current-account-context)))
+ (should (string-equal "home" (plist-get ctx :name)))
+ (should (string-equal "home" pearl-active-account)))))
+
+(ert-deftest test-pearl-current-account-context-no-default-errors ()
+ "Accounts set with neither an active nor a default account errors."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-default-account nil)
+ (pearl-active-account nil))
+ (should-error (pearl--current-account-context) :type 'error)))
+
+(ert-deftest test-pearl-current-account-context-uses-active-over-default ()
+ "An explicit active account wins over the configured default."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-default-account "home")
+ (pearl-active-account "work"))
+ (should (string-equal "work" (plist-get (pearl--current-account-context) :name)))))
+
+;;; pearl--with-account-context macro
+
+(ert-deftest test-pearl-with-account-context-binds-globals ()
+ "Inside the macro the legacy globals reflect the context's values."
+ (let ((pearl-api-key "legacy") (pearl-graphql-url "legacy-url")
+ (pearl-org-file-path "/legacy.org") (pearl-default-team-id "LEGACY"))
+ (pearl--with-account-context
+ '(:name "work" :api-key "kw" :url "https://w.test/gql"
+ :org-file "/tmp/w.org" :default-team-id "TEAM_W")
+ (should (string-equal "kw" pearl-api-key))
+ (should (string-equal "https://w.test/gql" pearl-graphql-url))
+ (should (string-equal "/tmp/w.org" pearl-org-file-path))
+ (should (string-equal "TEAM_W" pearl-default-team-id)))))
+
+(ert-deftest test-pearl-with-account-context-nil-is-passthrough ()
+ "A nil context leaves the globals untouched (legacy single-account mode)."
+ (let ((pearl-api-key "legacy") (pearl-org-file-path "/legacy.org"))
+ (pearl--with-account-context nil
+ (should (string-equal "legacy" pearl-api-key))
+ (should (string-equal "/legacy.org" pearl-org-file-path)))))
+
+(ert-deftest test-pearl-with-account-context-restores-after ()
+ "The globals are restored to their prior values after the macro body."
+ (let ((pearl-api-key "legacy") (pearl-org-file-path "/legacy.org"))
+ (pearl--with-account-context
+ '(:name "work" :api-key "kw" :url "u" :org-file "/tmp/w.org")
+ (ignore))
+ (should (string-equal "legacy" pearl-api-key))
+ (should (string-equal "/legacy.org" pearl-org-file-path))))
+
+;;; Dispatch-time snapshot (HP1)
+
+(ert-deftest test-pearl-request-async-callback-runs-under-dispatch-context ()
+ "request-async re-establishes the dispatch-time context around its callback.
+A switch to another account after dispatch must not bleed into the callback —
+the callback sees the key and org-file of the account that was active when the
+request fired."
+ (let ((pearl-accounts
+ (list (list "work" :api-key-source '(:literal "kw") :org-file "/tmp/w.org")
+ (list "home" :api-key-source '(:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-default-account "work")
+ (pearl-active-account "work")
+ (pearl--account-context nil)
+ (captured nil) (seen-key nil) (seen-file nil))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args) (setq captured (plist-get args :success)))))
+ (pearl--graphql-request-async
+ "q" nil
+ (lambda (_data) (setq seen-key pearl-api-key
+ seen-file pearl-org-file-path))))
+ ;; The user switches accounts before the response lands.
+ (setq pearl-active-account "home")
+ (funcall captured :data '((data)))
+ (should (string-equal "kw" seen-key))
+ (should (string-equal (expand-file-name "/tmp/w.org") seen-file))))
+
+(ert-deftest test-pearl-request-async-uses-active-account-key-at-dispatch ()
+ "With accounts configured, request-async builds headers from the active account."
+ (let ((pearl-accounts
+ (list (list "work" :api-key-source '(:literal "lin_work_dispatch")
+ :org-file "/tmp/w.org")))
+ (pearl-default-account "work")
+ (pearl-active-account "work")
+ (pearl--account-context nil)
+ (seen-auth nil))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (setq seen-auth (cdr (assoc "Authorization" (plist-get args :headers)))))))
+ (pearl--graphql-request-async "q" nil #'ignore))
+ (should (string-equal "lin_work_dispatch" seen-auth))))
+
+(ert-deftest test-pearl-request-async-legacy-mode-uses-global-key ()
+ "With no accounts configured, request-async uses the legacy global key unchanged."
+ (let ((pearl-accounts nil)
+ (pearl-active-account nil)
+ (pearl-default-account nil)
+ (pearl-api-key "lin_legacy_global")
+ (pearl--account-context nil)
+ (seen-auth nil))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (setq seen-auth (cdr (assoc "Authorization" (plist-get args :headers)))))))
+ (pearl--graphql-request-async "q" nil #'ignore))
+ (should (string-equal "lin_legacy_global" seen-auth))))
+
+;;; #+LINEAR-ACCOUNT header + buffer ownership
+
+(ert-deftest test-pearl-read-buffer-account-present ()
+ "The buffer-account reader returns the #+LINEAR-ACCOUNT name."
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-ACCOUNT: work\n#+LINEAR-SOURCE: (:type filter)\n")
+ (should (string-equal "work" (pearl--read-buffer-account)))))
+
+(ert-deftest test-pearl-read-buffer-account-absent ()
+ "A buffer with no #+LINEAR-ACCOUNT marker reads as nil."
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-SOURCE: (:type filter)\n")
+ (should (null (pearl--read-buffer-account)))))
+
+(ert-deftest test-pearl-build-org-content-emits-account-header ()
+ "When an account name is supplied, the content carries #+LINEAR-ACCOUNT."
+ (let ((content (pearl--build-org-content nil nil nil nil "work")))
+ (should (string-match-p "^#\\+LINEAR-ACCOUNT: work$" content))))
+
+(ert-deftest test-pearl-build-org-content-omits-account-header-in-legacy ()
+ "With no account name the content omits #+LINEAR-ACCOUNT (legacy file)."
+ (let ((content (pearl--build-org-content nil nil nil nil nil)))
+ (should-not (string-match-p "LINEAR-ACCOUNT" content))))
+
+;;; pearl--require-account-context buffer guard
+
+(ert-deftest test-pearl-require-account-context-legacy-returns-nil ()
+ "In legacy mode the guard is a no-op, even for a mutation."
+ (let ((pearl-accounts nil) (pearl-active-account nil) (pearl-default-account nil))
+ (with-temp-buffer
+ (should (null (pearl--require-account-context t))))))
+
+(ert-deftest test-pearl-require-account-context-matching-account-proceeds ()
+ "A buffer owned by the active account passes the guard and yields its context."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work"))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n")
+ (let ((ctx (pearl--require-account-context t)))
+ (should (string-equal "work" (plist-get ctx :name)))))))
+
+(ert-deftest test-pearl-require-account-context-wrong-account-refuses-mutation ()
+ "A mutation from another account's file refuses, naming both accounts."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home"))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n")
+ (let ((err (should-error (pearl--require-account-context t) :type 'error)))
+ (should (string-match-p "work" (error-message-string err)))
+ (should (string-match-p "home" (error-message-string err)))))))
+
+(ert-deftest test-pearl-require-account-context-wrong-account-refuses-nonmutation ()
+ "Even a non-mutating buffer command refuses against another account's file —
+fetching the active account's issues into it would cross workspaces."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home"))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n")
+ (should-error (pearl--require-account-context nil) :type 'error))))
+
+(ert-deftest test-pearl-require-account-context-unmarked-mutation-refuses ()
+ "An unmarked buffer refuses a mutation until a refresh stamps ownership."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work"))
+ (with-temp-buffer
+ (insert "#+LINEAR-SOURCE: (:type filter)\n")
+ (should-error (pearl--require-account-context t) :type 'error))))
+
+(ert-deftest test-pearl-require-account-context-unmarked-nonmutation-proceeds ()
+ "An unmarked buffer lets a render/list command through (it stamps ownership)."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work"))
+ (with-temp-buffer
+ (insert "#+LINEAR-SOURCE: (:type filter)\n")
+ (let ((ctx (pearl--require-account-context nil)))
+ (should (string-equal "work" (plist-get ctx :name)))))))
+
+;;; Guard wiring — commands refuse before any API call
+
+(ert-deftest test-pearl-save-issue-refuses-wrong-account-buffer ()
+ "`pearl-save-issue' from another account's buffer errors before touching the API."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home")
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (with-temp-buffer
+ (insert "#+LINEAR-ACCOUNT: work\n#+LINEAR-SOURCE: (:type filter)\n"
+ "* View\n** TODO thing\n:PROPERTIES:\n:LINEAR-ID: abc\n:END:\n")
+ (org-mode)
+ (goto-char (point-max))
+ (should-error (pearl-save-issue) :type 'error)
+ (should-not api-called)))))
+
+(ert-deftest test-pearl-edit-description-refuses-unmarked-buffer ()
+ "`pearl-edit-description' refuses to mutate an unmarked buffer under accounts."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work")
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (with-temp-buffer
+ (insert "#+LINEAR-SOURCE: (:type filter)\n* View\n** TODO thing\n"
+ ":PROPERTIES:\n:LINEAR-ID: abc\n:END:\n")
+ (org-mode)
+ (goto-char (point-max))
+ (should-error (pearl-edit-description) :type 'error)
+ (should-not api-called)))))
+
+;;; Cache isolation + switch + indicator
+
+(ert-deftest test-pearl-clear-account-scoped-state-nils-every-cache ()
+ "The helper clears every account-scoped cache it names."
+ (let ((pearl--cache-teams '(x)) (pearl--cache-states '(x))
+ (pearl--cache-team-collections '(x)) (pearl--cache-views '(x))
+ (pearl--cache-viewer '(:id "v")))
+ (pearl--clear-account-scoped-state)
+ (should (null pearl--cache-teams))
+ (should (null pearl--cache-states))
+ (should (null pearl--cache-team-collections))
+ (should (null pearl--cache-views))
+ (should (null pearl--cache-viewer))))
+
+(ert-deftest test-pearl-switch-account-sets-active-and-clears-caches ()
+ "Switching accounts sets `pearl-active-account' and clears the lookup caches."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w-nope.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h-nope.org")))
+ (pearl-active-account "work")
+ (pearl--cache-teams '(stale)) (pearl--cache-viewer '(:id "stale")))
+ (pearl-switch-account "home")
+ (should (string-equal "home" pearl-active-account))
+ (should (null pearl--cache-teams))
+ (should (null pearl--cache-viewer))))
+
+(ert-deftest test-pearl-switch-account-unknown-name-errors ()
+ "Switching to an unconfigured account errors (via the resolver)."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work"))
+ (should-error (pearl-switch-account "ghost") :type 'error)))
+
+(ert-deftest test-pearl-mode-line-lighter-shows-active-account ()
+ "The lighter names the active account when accounts are configured."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work"))
+ (should (string-equal " Pearl[work]" (pearl--mode-line-lighter)))))
+
+(ert-deftest test-pearl-mode-line-lighter-plain-in-legacy ()
+ "Without accounts the lighter is the plain \" Pearl\"."
+ (let ((pearl-accounts nil) (pearl-active-account nil))
+ (should (string-equal " Pearl" (pearl--mode-line-lighter)))))
+
+;;; Saved-query :account guard
+
+(ert-deftest test-pearl-run-saved-query-refuses-other-account-before-network ()
+ "A saved query tagged for another account refuses before any compilation or fetch."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")
+ ("home" :api-key-source (:literal "kh") :org-file "/tmp/h.org")))
+ (pearl-active-account "home") (pearl-default-account "home")
+ (pearl-saved-queries '(("Work bugs" :account "work" :filter (:assignee :me))))
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (let ((err (should-error (pearl-run-saved-query "Work bugs") :type 'error)))
+ (should (string-match-p "work" (error-message-string err))))
+ (should-not api-called))))
+
+(ert-deftest test-pearl-run-saved-query-account-match-proceeds-to-fetch ()
+ "A saved query tagged for the active account compiles and dispatches."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org")))
+ (pearl-active-account "work") (pearl-default-account "work")
+ (pearl-saved-queries '(("Work bugs" :account "work" :filter (:assignee :me))))
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (pearl-run-saved-query "Work bugs")
+ (should api-called))))
+
+(ert-deftest test-pearl-run-saved-query-account-tag-ignored-in-legacy ()
+ "With no accounts configured, an `:account' tag is inert (shared query)."
+ (let ((pearl-accounts nil) (pearl-active-account nil) (pearl-default-account nil)
+ (pearl-api-key "lin_legacy")
+ (pearl-saved-queries '(("Work bugs" :account "work" :filter (:assignee :me))))
+ (api-called nil))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) (setq api-called t))))
+ (pearl-run-saved-query "Work bugs")
+ (should api-called))))
+
+;;; pearl-load-api-key-from-env legacy-only
+
+(ert-deftest test-pearl-load-api-key-from-env-refuses-under-accounts ()
+ "The env loader refuses once accounts are configured, to not bypass the resolver."
+ (let ((pearl-accounts '(("work" :api-key-source (:literal "kw") :org-file "/tmp/w.org"))))
+ (should-error (pearl-load-api-key-from-env) :type 'error)))
+
+;;; Refresh stamps ownership on a legacy file (closes the guard's "refresh first" loop)
+
+(ert-deftest test-pearl-update-source-header-stamps-missing-account ()
+ "Refreshing an unmarked file under accounts mode stamps #+LINEAR-ACCOUNT,
+so a later mutation isn't permanently refused with \"refresh it under an
+account first\" — the refresh the guard tells the user to run must resolve."
+ (let ((pearl--account-context '(:name "work" :api-key "kw" :org-file "/tmp/w.org")))
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-SOURCE: (:type filter)\n"
+ "#+LINEAR-RUN-AT: 2020-01-01 00:00\n#+LINEAR-COUNT: 0\n"
+ "#+LINEAR-TRUNCATED: no\n* View\n")
+ (pearl--update-source-header 1 nil)
+ (should (string-equal "work" (pearl--read-buffer-account))))))
+
+(ert-deftest test-pearl-update-source-header-leaves-existing-account ()
+ "A file already stamped keeps its marker untouched on refresh."
+ (let ((pearl--account-context '(:name "work" :api-key "kw" :org-file "/tmp/w.org")))
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-ACCOUNT: work\n#+LINEAR-SOURCE: (:type filter)\n"
+ "#+LINEAR-RUN-AT: 2020-01-01 00:00\n#+LINEAR-COUNT: 0\n"
+ "#+LINEAR-TRUNCATED: no\n* View\n")
+ (pearl--update-source-header 1 nil)
+ (should (string-equal "work" (pearl--read-buffer-account)))
+ ;; exactly one marker, not duplicated
+ (should (= 1 (count-matches "^#\\+LINEAR-ACCOUNT:" (point-min) (point-max)))))))
+
+(ert-deftest test-pearl-update-source-header-legacy-mode-no-stamp ()
+ "In legacy mode (no bound context) refresh adds no account marker."
+ (let ((pearl--account-context nil))
+ (with-temp-buffer
+ (insert "#+title: x\n#+LINEAR-SOURCE: (:type filter)\n"
+ "#+LINEAR-RUN-AT: 2020-01-01 00:00\n#+LINEAR-COUNT: 0\n"
+ "#+LINEAR-TRUNCATED: no\n* View\n")
+ (pearl--update-source-header 1 nil)
+ (should (null (pearl--read-buffer-account))))))
+
+(provide 'test-pearl-accounts)
+;;; test-pearl-accounts.el ends here