aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
committerCraig Jennings <c@cjennings.net>2026-05-24 13:44:34 -0500
commitb081d62276378b3168c92c06153fd59db0589535 (patch)
tree9be7f7d22e0c9b4a73432fe744c09bb456c671a9
downloadpearl-b081d62276378b3168c92c06153fd59db0589535.tar.gz
pearl-b081d62276378b3168c92c06153fd59db0589535.zip
feat: pearl — manage Linear issues from org-mode
Pearl fetches Linear issues into an org file and syncs edits back. It covers list / custom views / saved queries, per-issue and bulk rendering with comments inline, conflict-aware sync of descriptions, titles, and comments, field commands for priority / state / assignee / labels, and a transient dispatch menu. The render folds to a scannable outline and nests issues under a sortable parent. Based on and inspired by Gael Blanchemain's linear-emacs.
-rw-r--r--.gitignore17
-rw-r--r--Eask25
-rw-r--r--LICENSE674
-rw-r--r--Makefile156
-rw-r--r--README.org335
-rw-r--r--TESTING.org66
-rw-r--r--docs/issue-comment-editing-spec.org117
-rw-r--r--docs/issue-conflict-handling-spec.org74
-rw-r--r--docs/issue-query-spec.org258
-rw-r--r--docs/issue-representation-spec.org230
-rw-r--r--docs/issue-sort-order-spec.org65
-rw-r--r--docs/labels-as-org-tags-spec.org81
-rw-r--r--docs/todo-keywords-from-workflow-states-spec.org209
-rw-r--r--package-summary.md28
-rw-r--r--pearl.el3527
-rw-r--r--tests/Makefile295
-rw-r--r--tests/check-deps.el38
-rw-r--r--tests/run-coverage-file.el50
-rw-r--r--tests/test-bootstrap.el47
-rw-r--r--tests/test-pearl-adhoc.el106
-rw-r--r--tests/test-pearl-api.el90
-rw-r--r--tests/test-pearl-assignee-labels.el114
-rw-r--r--tests/test-pearl-commands.el153
-rw-r--r--tests/test-pearl-comment-editing.el240
-rw-r--r--tests/test-pearl-comments.el170
-rw-r--r--tests/test-pearl-config.el76
-rw-r--r--tests/test-pearl-conflict.el282
-rw-r--r--tests/test-pearl-convert.el172
-rw-r--r--tests/test-pearl-delete.el97
-rw-r--r--tests/test-pearl-fields.el153
-rw-r--r--tests/test-pearl-filter.el193
-rw-r--r--tests/test-pearl-fixtures.el68
-rw-r--r--tests/test-pearl-format.el188
-rw-r--r--tests/test-pearl-issues.el73
-rw-r--r--tests/test-pearl-mapping.el145
-rw-r--r--tests/test-pearl-menu.el73
-rw-r--r--tests/test-pearl-merge.el252
-rw-r--r--tests/test-pearl-normalize.el138
-rw-r--r--tests/test-pearl-open.el66
-rw-r--r--tests/test-pearl-org-parse.el152
-rw-r--r--tests/test-pearl-org-write.el85
-rw-r--r--tests/test-pearl-output.el145
-rw-r--r--tests/test-pearl-query.el151
-rw-r--r--tests/test-pearl-refresh.el148
-rw-r--r--tests/test-pearl-resolve.el141
-rw-r--r--tests/test-pearl-result.el98
-rw-r--r--tests/test-pearl-saved.el112
-rw-r--r--tests/test-pearl-smoke.el37
-rw-r--r--tests/test-pearl-states.el134
-rw-r--r--tests/test-pearl-surface.el81
-rw-r--r--tests/test-pearl-sync-hooks.el175
-rw-r--r--tests/test-pearl-sync-wrappers.el83
-rw-r--r--tests/test-pearl-sync.el206
-rw-r--r--tests/test-pearl-teams.el109
-rw-r--r--tests/test-pearl-title-sync.el165
-rw-r--r--tests/test-pearl-views.el130
-rw-r--r--tests/testutil-fixtures.el105
-rw-r--r--tests/testutil-request.el49
58 files changed, 11447 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4bc61c3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,17 @@
+# Per-project tooling (local only)
+.ai/
+/todo.org
+
+# --- elisp build / test tooling ---
+*.elc
+*.eln
+/.eask/
+/.coverage/
+/makefile-local
+/tests/makefile-local
+/tests/*-output.log
+/pearl-autoloads.el
+/tests/tests-autoloads.el
+
+# Emacs backup files
+*~
diff --git a/Eask b/Eask
new file mode 100644
index 0000000..3d25a3c
--- /dev/null
+++ b/Eask
@@ -0,0 +1,25 @@
+;; -*- mode: eask; lexical-binding: t -*-
+
+(package "pearl"
+ "1.0.0"
+ "Linear.app integration")
+
+(website-url "https://github.com/cjennings/pearl")
+(keywords "tools")
+
+(package-file "pearl.el")
+
+(source 'gnu)
+(source 'nongnu)
+(source 'melpa)
+
+(depends-on "emacs" "27.1")
+(depends-on "request" "0.3.0")
+(depends-on "dash" "2.17.0")
+(depends-on "s" "1.12.0")
+(depends-on "transient" "0.3.0")
+
+(development
+ (depends-on "elisp-lint")
+ (depends-on "package-lint")
+ (depends-on "undercover"))
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ 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 <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..621cf95
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,156 @@
+# Makefile for pearl.el
+# Test targets delegate to tests/Makefile.
+# setup / compile / coverage operate at project root.
+# Run 'make help' for available commands.
+
+EASK ?= eask
+EMACS_BATCH = $(EASK) emacs --batch
+# Coverage / test loops need default-directory = tests/ so test files'
+# relative paths (../pearl.el, sibling test files) resolve as they do
+# under tests/Makefile.
+EMACS_BATCH_TESTS = $(EASK) emacs --batch --eval '(cd "tests/")'
+
+TEST_DIR = tests
+SOURCE_FILE = pearl.el
+
+# Coverage configuration
+COVERAGE_DIR = .coverage
+COVERAGE_FILE = $(COVERAGE_DIR)/simplecov.json
+
+# Test-file list used by the coverage loop, mirroring tests/Makefile.
+# Coverage runs every test file so the report represents the full suite.
+ALL_TESTS = $(filter-out $(TEST_DIR)/test-bootstrap.el, \
+ $(wildcard $(TEST_DIR)/test-*.el))
+
+# Include local overrides if present (per-machine knobs, not committed)
+-include makefile-local
+
+.PHONY: help test test-all test-unit test-integration test-file test-one test-name \
+ count list validate lint check-deps clean \
+ setup compile coverage coverage-clean
+
+help:
+ @$(MAKE) -C $(TEST_DIR) help
+
+# Test target delegations
+test:
+ @$(MAKE) -C $(TEST_DIR) test
+
+test-all:
+ @$(MAKE) -C $(TEST_DIR) test-all
+
+test-unit:
+ @$(MAKE) -C $(TEST_DIR) test-unit
+
+test-integration:
+ @$(MAKE) -C $(TEST_DIR) test-integration
+
+test-file:
+ @$(MAKE) -C $(TEST_DIR) test-file FILE="$(FILE)"
+
+test-one:
+ @$(MAKE) -C $(TEST_DIR) test-one TEST="$(TEST)"
+
+test-name:
+ @$(MAKE) -C $(TEST_DIR) test-name TEST="$(TEST)"
+
+count:
+ @$(MAKE) -C $(TEST_DIR) count
+
+list:
+ @$(MAKE) -C $(TEST_DIR) list
+
+validate:
+ @$(MAKE) -C $(TEST_DIR) validate
+
+lint:
+ @$(MAKE) -C $(TEST_DIR) lint
+
+check-deps:
+ @$(MAKE) -C $(TEST_DIR) check-deps
+
+clean:
+ @$(MAKE) -C $(TEST_DIR) clean
+ @rm -rf $(COVERAGE_DIR)
+
+#
+# Project-root targets — operate on pearl.el at root level
+#
+
+# Install runtime + development dependencies via eask
+setup:
+ @if ! command -v $(EASK) >/dev/null 2>&1; then \
+ echo "[✗] eask not found on PATH"; \
+ echo " Install: npm install -g @emacs-eask/cli"; \
+ echo " Or: https://emacs-eask.github.io/Getting-Started/Install-Eask/"; \
+ exit 1; \
+ fi
+ @echo "[i] Installing dependencies via eask..."
+ @$(EASK) install-deps --dev
+ @echo "[i] Verifying runtime deps landed in the sandbox..."
+ @# eask skips a dep when an identical version sits in its GLOBAL store
+ @# (~/.eask/<ver>/elpa/), but `eask emacs' only activates the project
+ @# sandbox — so a globally-present dep is "installed" yet unloadable here.
+ @# Install any such skipped runtime dep straight into the sandbox.
+ @$(EASK) emacs --batch --eval "(progn \
+ (package-initialize) \
+ (dolist (pkg '(request dash s)) \
+ (unless (package-installed-p pkg) \
+ (message \" installing %s into sandbox...\" pkg) \
+ (unless package-archive-contents (package-refresh-contents)) \
+ (package-install pkg))))"
+ @echo "[✓] Dependencies installed in .eask/"
+
+# Byte-compile pearl.el — surfaces free-variable / unused-let /
+# suspicious-call warnings that checkdoc and elisp-lint don't catch.
+# byte-compile-error-on-warn is t so any warning fails the build.
+compile:
+ @echo "[i] Byte-compiling $(SOURCE_FILE)..."
+ @$(EMACS_BATCH) \
+ --eval "(progn \
+ (setq byte-compile-error-on-warn t) \
+ (batch-byte-compile))" $(SOURCE_FILE)
+ @echo "[✓] Compilation complete"
+
+#
+# Coverage (undercover + simplecov JSON)
+#
+# Each test file runs in its own Emacs process (matching test-unit);
+# tests/run-coverage-file.el instruments pearl.el before the source
+# is loaded, and undercover merges per-file results into a single simplecov
+# JSON.
+
+coverage: coverage-clean $(COVERAGE_DIR)
+ @echo "[i] Cleaning .elc files so undercover can instrument source..."
+ @find . -name "*.elc" -delete
+ @echo "[i] Running coverage across $(words $(ALL_TESTS)) test file(s)..."
+ @echo " (slower than 'make test' — each file runs in its own Emacs)"
+ @failed=0; \
+ for test in $(ALL_TESTS); do \
+ echo " Coverage: $$test..."; \
+ testfile=$$(basename $$test); \
+ $(EMACS_BATCH_TESTS) \
+ -l ert \
+ -l run-coverage-file.el \
+ -l ../$(SOURCE_FILE) \
+ -l $$testfile \
+ --eval "(ert-run-tests-batch-and-exit t)" || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -gt 0 ]; then \
+ echo "[!] $$failed test file(s) failed during coverage run"; \
+ exit 1; \
+ fi
+ @coverage_file="$${COVERAGE_FILE_ACTUAL:-$(COVERAGE_FILE)}"; \
+ [ -n "$$CI" ] && coverage_file="$(COVERAGE_DIR)/coveralls.json"; \
+ if [ -f "$$coverage_file" ]; then \
+ echo "[✓] Coverage report: $$coverage_file ($$(du -h $$coverage_file | cut -f1))"; \
+ else \
+ echo "[!] No coverage file produced; check that undercover is installed"; \
+ exit 1; \
+ fi
+
+coverage-clean:
+ @rm -f $(COVERAGE_FILE)
+
+$(COVERAGE_DIR):
+ @mkdir -p $(COVERAGE_DIR)
diff --git a/README.org b/README.org
new file mode 100644
index 0000000..633baf4
--- /dev/null
+++ b/README.org
@@ -0,0 +1,335 @@
+#+TITLE: pearl — Linear.app for Emacs
+#+OPTIONS: toc:2 num:nil
+
+[[https://www.gnu.org/licenses/gpl-3.0][https://img.shields.io/badge/License-GPLv3-blue.svg]]
+
+=pearl= is an integration between Emacs and [[https://linear.app][Linear.app]]: fetch, read, edit, and create Linear issues as org-mode entries without leaving Emacs. Issues render as an org outline; their descriptions and comments live in the entry body, structured fields live in a property drawer, and dedicated commands push changes back to Linear.
+
+* Features
+
+- *Fetch what you want.* List your open issues, narrow by project, build an ad-hoc filter interactively, run a Linear Custom View, or run a named local saved query.
+- *Issues as readable org.* Each issue is a heading; its description renders in the body (markdown converted to org), its comments render as a chronological sub-thread, and its structured fields (state, priority, assignee, labels, team, project) live in a namespaced =LINEAR-*= drawer.
+- *Edit and push back.* Edit a description in the body and sync it; change the title; set priority, state, assignee, or labels by command; add a comment, or edit one of your own. Each push is explicit and confirmed against the remote.
+- *Conflict-aware sync.* Pushing a description or title compares the local edit, the last-fetched baseline, and the current remote — a no-op sends nothing, a clean edit pushes, and a both-sides-changed case is refused and reported rather than clobbering.
+- *Self-describing active file.* The generated file records the source it came from, so one command refreshes it; refresh a single issue at point, or the whole view.
+- *Org TODO state sync.* Changing an issue's TODO keyword in org pushes the matching Linear workflow state.
+- *One menu for everything.* =M-x pearl-menu= opens a transient dispatcher (magit-style) with every command grouped and a key away.
+
+* Installation
+
+** Prerequisites
+
+A Linear API key (Settings → Account → API → Personal API Keys) and the =request=, =dash=, =s=, and =transient= packages (=transient= ships with Emacs 28+).
+
+#+begin_src elisp
+(require 'package)
+(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/") t)
+(package-initialize)
+#+end_src
+
+** MELPA (coming soon)
+
+#+begin_src
+M-x package-install RET pearl RET
+#+end_src
+
+** Manual
+
+#+begin_src shell
+git clone https://github.com/cjennings/pearl.git
+#+end_src
+
+#+begin_src elisp
+(add-to-list 'load-path "/path/to/pearl")
+(require 'pearl)
+;; dependencies, if not already present:
+;; M-x package-install RET request RET
+;; M-x package-install RET dash RET
+;; M-x package-install RET s RET
+#+end_src
+
+** Doom Emacs
+
+In =packages.el=:
+
+#+begin_src elisp
+(package! pearl
+ :recipe (:host github :repo "cjennings/pearl" :files ("*.el")))
+#+end_src
+
+In =config.el=:
+
+#+begin_src elisp
+(use-package! pearl
+ :commands (pearl-list-issues pearl-new-issue
+ pearl-run-view pearl-enable-org-sync)
+ :init
+ (setq pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory))
+ :config
+ (pearl-load-api-key-from-env))
+#+end_src
+
+* Configuration
+
+** The API key
+
+Three ways, least to most secure:
+
+#+begin_src elisp
+;; 1. Direct (not recommended -- ends up in your config).
+(setq pearl-api-key "lin_api_...")
+
+;; 2. Environment variable LINEAR_API_KEY.
+(pearl-load-api-key-from-env)
+#+end_src
+
+3. =auth-source= (recommended). Add to =~/.authinfo.gpg=:
+
+#+begin_src
+machine api.linear.app login apikey password YOUR_API_KEY
+#+end_src
+
+then load it:
+
+#+begin_src elisp
+(setq pearl-api-key
+ (auth-source-pick-first-password :host "api.linear.app"))
+#+end_src
+
+The host (=api.linear.app=) and login (=apikey=) must match the entry. macOS Keychain users can instead run =security add-generic-password -a apikey -s api.linear.app -w YOUR_API_KEY=.
+
+** Output file
+
+Issues are written to one active file, =pearl-org-file-path= (default =gtd/linear.org= under =org-directory=):
+
+#+begin_src elisp
+(setq pearl-org-file-path (expand-file-name "gtd/linear.org" org-directory))
+#+end_src
+
+Running a different query or view *replaces* this file's contents (behind a dirty-buffer guard); one issue appears in one place.
+
+** Default team
+
+#+begin_src elisp
+(setq pearl-default-team-id "your-team-id") ; skips the team prompt on create
+#+end_src
+
+* The command menu
+
+=M-x pearl-menu= opens a transient dispatcher with every command grouped by what it does: fetch, view, the issue at point, create and org-sync, and setup. It's the fastest way to reach a command without remembering its name. Bind it to a key if you use it often:
+
+#+begin_src elisp
+(with-eval-after-load 'pearl
+ (global-set-key (kbd "C-c L") #'pearl-menu))
+#+end_src
+
+Every command below is also available directly via =M-x=.
+
+* The active org file
+
+A fetched file carries a self-describing header and one heading per issue:
+
+#+begin_src org
+#+title: Linear — My open issues
+#+STARTUP: show3levels
+#+TODO: TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE
+#+LINEAR-SOURCE: (:type filter :name "My open issues" :filter (:assignee :me :open t))
+#+LINEAR-RUN-AT: 2026-05-23 19:30
+#+LINEAR-FILTER: assignee: me, open
+#+LINEAR-COUNT: 12
+#+LINEAR-TRUNCATED: no
+#
+# Body = the issue description; edit it, then M-x pearl-sync-current-issue to push.
+# Comments subtree = the thread; add with M-x pearl-add-comment.
+# Drawer fields change via M-x pearl-set-priority / -state / -assignee / -labels.
+# Refresh with M-x pearl-refresh-current-view (whole file) or -current-issue (one).
+
+* My open issues
+** TODO [#B] ENG-123 Issue title
+:PROPERTIES:
+:LINEAR-ID: <uuid>
+:LINEAR-IDENTIFIER: ENG-123
+:LINEAR-URL: https://linear.app/.../ENG-123
+:LINEAR-TEAM-ID: <id>
+:LINEAR-TEAM-NAME: Engineering
+:LINEAR-PROJECT-NAME: Platform
+:LINEAR-STATE-NAME: In Progress
+:LINEAR-ASSIGNEE-NAME: Craig
+:LINEAR-LABELS: [bug, p1]
+:LINEAR-DESC-SHA256: <hash of the last-fetched description markdown>
+:LINEAR-DESC-ORG-SHA256: <hash of the rendered Org body>
+:LINEAR-DESC-UPDATED-AT: <remote timestamp>
+:LINEAR-TITLE-SHA256: <hash of the rendered title>
+:END:
+
+The issue description renders here as org, edited in place.
+
+*** Comments
+**** Author Name — 2026-05-23T10:00:00.000Z
+A comment, oldest first.
+#+end_src
+
+Every issue nests under one top-level heading named after the view (here =My open issues=), so you can sort the whole set with =C-c ^= on that parent. The =#+LINEAR-SOURCE:= line records what produced the file so =refresh-current-view= can re-run it. The =LINEAR-*= drawer stores ids and display names separately; commands show names and mutate by id, so there is no per-render network lookup. The =SHA256= properties are provenance for the sync conflict gate.
+
+* Fetching issues
+
+| Command | What it fetches |
+|---------+-----------------|
+| =pearl-list-issues= | your open issues |
+| =pearl-list-issues-by-project= | open issues in a chosen project |
+| =pearl-list-issues-filtered= | an ad-hoc filter built interactively |
+| =pearl-run-view= | a Linear Custom View, run server-side |
+| =pearl-run-saved-query= | a named local saved query |
+| =pearl-refresh-current-view= | re-runs the file's recorded source |
+| =pearl-refresh-current-issue= | re-fetches the issue at point |
+
+=list-issues-filtered= picks a team (which scopes the rest), then completes the state, project, and labels from that team's actual values rather than free text, so a typo can't silently return nothing. It can save the filter as a local query.
+
+** Saved queries
+
+Name local queries in =pearl-saved-queries=, then run them with =pearl-run-saved-query=:
+
+#+begin_src elisp
+(setq pearl-saved-queries
+ '(("My open work" :filter (:assignee :me :open t) :sort updated :order desc)
+ ("Open bugs" :filter (:labels ("bug") :open t) :sort priority :order asc)))
+#+end_src
+
+Each entry is a filter plist plus optional =:sort= (=updated=, =priority=, or =title=) and =:order= (=asc= / =desc=). Sorting happens after fetch, so a refresh always lays headings out the same way. Queries are AND-only; for OR logic, build a Linear Custom View and run it with =run-view=.
+
+* Editing issues
+
+All issue commands work from anywhere inside an issue's subtree.
+
+** Description and title
+
+Edit the description in the body, then =M-x pearl-sync-current-issue=. The push is gated: unchanged since fetch sends nothing; a local edit against an unchanged remote pushes; if the remote also changed since the fetch the push is refused and the conflict reported (refresh to reconcile). =pearl-sync-current-issue-title= does the same for the heading title (note: square brackets are stripped from titles, so a synced title drops them).
+
+** Fields
+
+| Command | Effect |
+|---------+--------|
+| =pearl-set-priority= | None / Urgent / High / Medium / Low |
+| =pearl-set-state= | a workflow state from the issue's team |
+| =pearl-set-assignee= | a team member |
+| =pearl-set-labels= | team labels (empty selection clears) |
+
+Each completes by display name, resolves to the Linear id, pushes, and updates the drawer.
+
+** Comments
+
+Comments render oldest-first under a =*** Comments= subtree, in both the bulk list and a single-issue refresh. =pearl-add-comment= posts a new comment and appends it.
+
+You can also edit your *own* comments. Edit a comment's body in place, then =M-x pearl-edit-current-comment= from inside its subtree. The push is gated like the description sync: unchanged sends nothing, a clean edit pushes, and a both-sides-changed case is refused (refresh to reconcile). Matching Linear's permissions, only comments you authored are editable — others (and bot or integration comments) are refused without a network call. Editability is shown by color: your own comments render green, everyone else's render greyed. Run =M-x pearl-highlight-comments= to recolor a buffer by hand.
+
+** Other commands
+
+| Command | Effect |
+|---------+--------|
+| =pearl-open-current-issue= | open the issue's URL in the browser |
+| =pearl-open-current-view-in-linear= | open the active view's URL |
+| =pearl-delete-current-issue= | delete the issue (confirms; soft delete to Trash) |
+| =pearl-new-issue= | create an issue |
+| =pearl-clear-cache= | clear the team/state/collection/view lookup caches |
+
+* State mapping and org TODO sync
+
+=pearl-state-to-todo-mapping= maps Linear workflow states to org TODO keywords for *rendering only* — it no longer decides which issues are fetched (inclusion is server-side via the filter). Default:
+
+#+begin_src elisp
+'(("Todo" . "TODO")
+ ("In Progress" . "IN-PROGRESS")
+ ("In Review" . "IN-REVIEW")
+ ("Backlog" . "BACKLOG")
+ ("Blocked" . "BLOCKED")
+ ("Done" . "DONE"))
+#+end_src
+
+Customize it to match your workflow, and make sure your =org-todo-keywords= include every keyword you map to. With org sync enabled, changing an issue's TODO keyword pushes the corresponding Linear state:
+
+#+begin_src elisp
+(add-hook 'find-file-hook
+ (lambda ()
+ (when (and buffer-file-name
+ (string-equal (file-truename buffer-file-name)
+ (file-truename pearl-org-file-path)))
+ (pearl-enable-org-sync))))
+#+end_src
+
+=pearl-enable-org-sync= / =pearl-disable-org-sync= toggle the save-and-TODO-change hooks for the active file.
+
+* Customization
+
+| Variable | Purpose |
+|----------+---------|
+| =pearl-org-file-path= | the active output file |
+| =pearl-state-to-todo-mapping= | Linear state ↔ org keyword (render/sync) |
+| =pearl-saved-queries= | named local queries |
+| =pearl-default-team-id= | default team for issue creation |
+| =pearl-max-issue-pages= | pagination cap (default 10) |
+| =pearl-request-timeout= | API request timeout (seconds) |
+| =pearl-fold-after-update= | re-fold the page after a fetch/refresh (default on) |
+| =pearl-surface-buffer= | bring the active buffer to a window after a command (default on) |
+| =pearl-surface-select-window= | also move focus to that window (default off) |
+| =pearl-debug= | enable debug logging |
+
+* FAQ
+
+** I edited the title in the heading and ran the description sync, but the title didn't change.
+
+Title and description push through separate commands. =pearl-sync-current-issue= pushes the body; the heading title is =pearl-sync-current-issue-title= (menu =t=). Fields and comments are their own commands too — each edit pushes independently.
+
+** My title lost its square brackets after syncing.
+
+The renderer strips =[= and =]= from titles so org doesn't misparse them, so the heading holds the stripped form and a title push sends that. =Fix [URGENT] bug= becomes =Fix URGENT bug=. Keep brackets out of titles you mean to round-trip.
+
+** A markdown heading or =*italic*= in a description turned into bold after I synced it.
+
+Two markdown constructs are intentionally lossy on the round-trip: a markdown =# heading= renders as a bold line on fetch and pushes back as bold (it never returns to a heading), and single-asterisk =*italic*= is read as bold. Edit a description containing either and push, and it comes back rewritten. Use =**bold**= and real Org sub-structure deliberately; don't rely on md headings surviving.
+
+** My comment edit was refused with "You can only edit your own comments."
+
+By design — Pearl matches Linear's permissions. Only comments you authored are editable; another person's comment, or a bot or integration comment, is refused with no API call. Your own comments render green, everyone else's greyed.
+
+** A sync or comment edit was refused as a conflict.
+
+The push is gated three ways: unchanged since fetch sends nothing; a local edit against an unchanged remote pushes; if the remote also changed since your last fetch, the push is refused and the conflict reported. Refresh to reconcile, or take the use-local / use-remote / merge prompt — any destructive choice stashes your local text in =*pearl-conflict-backup*= first, so nothing is lost.
+
+** I hand-edited a drawer field (priority, state, assignee, labels) and it didn't push.
+
+=LINEAR-*= drawer fields change by command, not by hand: =pearl-set-priority=, =-state=, =-assignee=, =-labels=. Each resolves a display name to the Linear id, pushes, and rewrites the drawer. A value typed into the drawer by hand won't push and gets overwritten on the next refresh.
+
+** My fetch seems to be missing issues.
+
+The fetch is capped at =pearl-max-issue-pages= (default 10 pages × 100 = 1000 issues). A capped fetch sets =#+LINEAR-TRUNCATED: yes= in the header. Raise =pearl-max-issue-pages= if you're assigned more.
+
+** Names (team, state, assignee) are stale after I renamed them in Linear.
+
+Pearl caches those lookups so it doesn't hit the network on every render. Run =M-x pearl-clear-cache=.
+
+** The page collapses after every refresh — can I keep it expanded?
+
+After a fetch or refresh Pearl re-folds the page to its =#+STARTUP= visibility (issue headings shown; descriptions, comments, and drawers folded), because =#+STARTUP= on its own only applies on a file's first visit. Set =pearl-fold-after-update= to nil to leave the buffer however you had it.
+
+** Can I hide the property drawers completely, not just fold them?
+
+Pearl folds drawers with the rest of the page, but an expanded issue still shows the one-line =:PROPERTIES:= stub. For a tidier look, [[https://github.com/jxq0/org-tidy][org-tidy]] replaces each drawer with a small glyph. It composes with Pearl rather than fighting it: Pearl folds the outline (descriptions and comments) to a scannable list, and org-tidy collapses the drawers on top — even on an expanded issue. Editing still works, since org-tidy only adds display overlays and never touches the drawer text, and a Pearl fetch re-applies it cleanly. Turn it on with =M-x org-tidy-mode= (or =global-org-tidy-mode=); if a partial refresh ever leaves a drawer showing, =M-x org-tidy= re-tidies.
+
+* Troubleshooting
+
+- =M-x pearl-test-connection= — check the API key and connectivity.
+- =M-x pearl-check-setup= — confirm the key loaded.
+- =M-x pearl-toggle-debug= (or =(setq pearl-debug t)=) — log requests and responses to =*Messages*=.
+- Stale names after renaming things in Linear → =M-x pearl-clear-cache=.
+
+* Contributing
+
+Contributions are welcome — open an issue or a pull request.
+
+* Acknowledgments
+
+Based on and inspired by Gael Blanchemain's linear-emacs.
+
+* License
+
+GPL-3.0. See [[file:LICENSE][LICENSE]].
diff --git a/TESTING.org b/TESTING.org
new file mode 100644
index 0000000..6d7abd2
--- /dev/null
+++ b/TESTING.org
@@ -0,0 +1,66 @@
+#+TITLE: Testing pearl
+#+OPTIONS: toc:2
+
+* Overview
+
+pearl uses [[https://github.com/jorgenschaefer/emacs-lisp-mode][ERT]] for tests, [[https://emacs-eask.github.io/][Eask]] to manage the test sandbox and dependencies, and [[https://github.com/undercover-el/undercover.el][undercover]] for coverage reports. A root =Makefile= drives everything and delegates test targets to =tests/Makefile=.
+
+* Prerequisites
+
+- Emacs 27.1+
+- [[https://emacs-eask.github.io/Getting-Started/Install-Eask/][Eask]] on =PATH= (=npm install -g @emacs-eask/cli=, or the standalone installer)
+
+* First-time setup
+
+#+begin_src shell
+make setup
+#+end_src
+
+Installs runtime deps (=request=, =dash=, =s=) and development deps (=elisp-lint=, =package-lint=, =undercover=) into a project-local =.eask/= sandbox.
+
+** Note on Eask's global package store
+
+Eask keeps a global package store at =~/.eask/<emacs-version>/elpa/=. When a dependency already sits there at the version the project wants, =eask install-deps= skips installing it into the project sandbox — but =eask emacs= (which runs the tests) only activates the project sandbox, so the dependency ends up "installed" yet unloadable. =make setup= guards against this: after =eask install-deps= it verifies each runtime dep is present in the sandbox and installs any that were skipped. The guard is a no-op where the global store is empty (fresh clones, CI).
+
+* Running tests
+
+#+begin_src shell
+make test # all tests, excluding :slow
+make test-all # all tests, including :slow
+make test-unit # unit tests only
+make test-integration # integration tests only
+make test-file FILE=mapping # one file (fuzzy match on name)
+make test-one TEST=priority # one test (fuzzy match on name)
+make test-name TEST='pattern' # tests matching an ERT name pattern
+#+end_src
+
+* Coverage
+
+#+begin_src shell
+make coverage
+#+end_src
+
+Cleans =.elc= files (so undercover can instrument the source), runs every test file in its own Emacs process with =pearl.el= instrumented, and merges per-file results into =.coverage/simplecov.json=. Under CI (=CI=true=) it emits =.coverage/coveralls.json= instead for upload.
+
+* Other targets
+
+#+begin_src shell
+make validate # parens-balance check (no deps needed)
+make compile # byte-compile pearl.el (warnings are errors)
+make lint # elisp-lint over pearl.el
+make count # count tests per file
+make list # list all test names
+make clean # remove .elc, logs, and .coverage
+make help # full target list
+#+end_src
+
+=make compile= (warnings as errors), =make lint=, and =eask lint checkdoc= are all clean; keep them that way as part of normal maintenance.
+
+* Writing tests
+
+- One test file per source function/area: =tests/test-pearl-<area>.el=.
+- Start each file with =(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))= — it loads the deps and the package source.
+- Name tests =test-pearl-<function>-<scenario>-<expected>=.
+- Cover Normal, Boundary, and Error cases.
+- Tag slow tests with =:tags '(:slow)= so the default =make test= skips them.
+- Integration files are named =tests/test-integration-*.el=.
diff --git a/docs/issue-comment-editing-spec.org b/docs/issue-comment-editing-spec.org
new file mode 100644
index 0000000..6a1b0c8
--- /dev/null
+++ b/docs/issue-comment-editing-spec.org
@@ -0,0 +1,117 @@
+#+TITLE: pearl — Comment Editing Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+*APPROVED — open questions resolved 2026-05-24 (see [[*Resolved decisions][Resolved decisions]]). Implementation in progress.* Nothing in =pearl.el= had changed at the time of writing.
+
+Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (rendering + description/title editing) and [[file:issue-query-spec.org][issue-query-spec.org]] (fetching). This doc covers the one editing path those two parked as vNext: editing an existing comment. It reuses their org→Linear write path, their conflict-gate pattern, and the single active-file model.
+
+* Problem
+
+Comments are render-and-add only. You can read the thread and post a new comment, but you can't fix a typo in your own comment without leaving Emacs for the Linear web UI. Linear lets a user edit only their own comments, so the feature has to carry a permission check: a comment authored by someone else (or by a bot or integration) must not be editable from Emacs, and the attempt must fail clearly rather than bounce off the server with an opaque error.
+
+The representation spec already parked this (its decision 2): "editing existing comments is vNext, and then only comments authored by the current Linear user, matching Linear's permissions." This is that vNext.
+
+* Current state (what exists today)
+
+- *Fetch.* =pearl--fetch-issue-async= (=pearl.el:~737=) pulls each comment as =id=, =body=, =createdAt=, =user { id name displayName }=, =botActor { name }=, =externalUser { name }=. The single-issue fetch carries comments; the bulk list omits them.
+- *Normalize.* =pearl--normalize-comment= (=l.568=) returns =(:id :body :created-at :author)=. The =:author= is the *display name only* — the user's =id= is fetched but dropped. There is no viewer identity anywhere in the package.
+- *Render.* =pearl--format-comment= (=l.1612=) renders =***** <author> — <timestamp>= followed by the body (markdown → org). The comment =id= is not written into the org; nothing per-comment is recoverable after render.
+- *Add.* =pearl--create-comment-async= (=l.1949=, =commentCreate=) + =pearl--append-comment-to-issue= (=l.1975=).
+- *Conflict pattern to reuse.* Description sync (representation spec) hashes the last-fetched body into =LINEAR-DESC-SHA256=, compares last-fetched / current-org / current-remote, and does no-op / push / refuse-on-both-changed.
+
+Three things are therefore missing for editing: the *viewer's identity*, per-comment *id + author id + provenance* in the org, and a =commentUpdate= write path with the same conflict gate.
+
+* Proposed design
+
+** 1. Viewer identity
+
+Add an async =viewer { id name }= query with a cached id, mirroring the team/state caches:
+
+- =pearl--viewer-async (callback)= → normalized =(:id :name)=.
+- =pearl--viewer-id= → cached id, fetched once per session.
+- Add the viewer cache to =pearl-clear-cache=.
+
+This is the identity the permission check compares against.
+
+** 2. Retain the comment author id
+
+Extend =pearl--normalize-comment= to keep =:author-id= (the =user.id=). Bot and external comments have no editable user, so =:author-id= is nil for them — which the permission check reads as "not editable."
+
+** 3. Per-comment provenance in the org
+
+To target a comment for =commentUpdate= and to decide editability, each rendered comment heading needs its id, its author id, and a body hash. A small property drawer under each =*****= comment heading, mirroring the issue drawer:
+
+#+begin_src org
+**** Comments
+***** Craig — 2026-05-24T10:00:00.000Z
+:PROPERTIES:
+:LINEAR-COMMENT-ID: <uuid>
+:LINEAR-COMMENT-AUTHOR-ID: <user-uuid or empty>
+:LINEAR-COMMENT-SHA256: <hash of the last-fetched body>
+:END:
+The comment body renders here as org, edited in place.
+#+end_src
+
+=org-tidy= folds the drawer the same way it folds the issue drawer, so the thread still reads cleanly. The =SHA256= is the last-fetched-body provenance for the conflict gate, exactly like =LINEAR-DESC-SHA256=.
+
+** 4. The edit command
+
+=pearl-edit-current-comment= (name is an open question), run from anywhere inside a comment's subtree:
+
+1. Locate the enclosing =*****= comment heading and read its drawer.
+2. *Permission gate.* If =LINEAR-COMMENT-AUTHOR-ID= is empty or ≠ the viewer id, =user-error= "You can only edit your own comments" and stop. No network call.
+3. Render the comment's current org body to markdown (the description sync's org→md path).
+4. *Conflict gate* (mirrors description sync, v1 = detect / refuse / message):
+ - current org-rendered hash = =LINEAR-COMMENT-SHA256= → unchanged → no-op, no API call.
+ - changed locally, remote unchanged since fetch → =commentUpdate= push.
+ - both changed (re-fetch the remote comment body; its hash ≠ the stored last-fetched hash) → refuse, report, suggest refresh.
+5. On success, update =LINEAR-COMMENT-SHA256= and re-render the comment body from the returned comment.
+
+** 5. The write path
+
+=pearl--update-comment-async (comment-id body callback)= over =commentUpdate(id: $id, input: { body: $body }) { success comment { ... } }=, normalizing the returned comment. (*Exact mutation shape to be live-verified during implementation*, the way =commentCreate= and the issue mutations were verified against the real workspace.)
+
+** 6. Editability highlighting (own = green, others = grey)
+
+"Comments by other users must not appear editable." The permission gate in step 4.2 enforces the behavior; this section makes it *visible* so a user sees what's editable before trying.
+
+Each comment heading is colored by editability when the active file is displayed and after every refresh:
+
+- the viewer's own comments → =pearl-editable-comment= face (green),
+- everyone else's, plus bot and external comments → =pearl-readonly-comment= face (greyed, inherits =shadow=, reads as disabled).
+
+Two custom faces so users can theme them. Because the active file is generated and written to disk, faces can't be stored in the file — they're applied at *display time*. Mechanism (proposed): an overlay pass that runs during render and re-runs on the refresh / find-file hook, reading each comment's =LINEAR-COMMENT-AUTHOR-ID= drawer and comparing it to the cached viewer id. Overlays are preferred over a font-lock matcher because they don't contend with org's own fontification and the highlighted set is small. The viewer id must be resolved before the highlight pass — fetch it alongside the single-issue fetch that already pulls comments, so it's in hand at render.
+
+** 7. Refresh interaction
+
+Refreshing the issue (=refresh-current-issue=) replaces the subtree, so an unpushed comment edit would be lost — the same risk description edits already carry. The existing dirty-buffer guard covers it; no new merge logic in v1.
+
+* Proposed v1 decisions
+
+1. Only the viewer's own comments are editable. Others' comments (and bot/external comments) refuse with a =user-error=, no network call.
+2. Each rendered comment carries a drawer with its id, author id, and last-fetched body hash.
+3. Conflict handling is detect / refuse / message — identical to description sync v1. Interactive merge is vNext.
+4. Edit-in-place: edit the comment's org body, then run the command from inside the comment subtree (consistent with how descriptions sync). No separate prompt buffer.
+5. Comment *deletion* stays out of scope (read / add / edit only). Deletion is its own vNext item if wanted.
+6. Editability is shown by color: own comments green, others greyed (decision from the 2026-05-24 review).
+7. The edit command is named =pearl-edit-current-comment= and is added to the transient menu under "Issue at point."
+
+* vNext (out of scope here)
+
+- Comment deletion.
+- Interactive conflict resolution (diff / local-wins / remote-wins) — shared with the description/title conflict vNext.
+- Editing via a dedicated prompt buffer instead of in-place.
+- Threaded replies (parent comment id).
+
+* Resolved decisions
+
+Settled with Craig, 2026-05-24:
+
+1. *Per-comment drawer* — yes. Each comment heading carries =LINEAR-COMMENT-ID= / =-AUTHOR-ID= / =-SHA256=, consistent with the issue drawer.
+2. *Editability visibility* — refuse is enough for behavior, plus color: others' comments render greyed (disabled-looking), the viewer's own render green (see [[*6. Editability highlighting (own = green, others = grey)][Editability highlighting]]).
+3. *Command name* — =pearl-edit-current-comment=.
+4. *Transient* — yes, add it under "Issue at point" once implemented.
diff --git a/docs/issue-conflict-handling-spec.org b/docs/issue-conflict-handling-spec.org
new file mode 100644
index 0000000..09acaf6
--- /dev/null
+++ b/docs/issue-conflict-handling-spec.org
@@ -0,0 +1,74 @@
+#+TITLE: pearl — Interactive Conflict Handling Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+*IMPLEMENTED (2026-05-24).* Shipped in two increments: the use-local / use-remote / cancel core, then the smerge rewrite-in-buffer path and the refresh hardening. The open questions are resolved; see "Decisions" at the end.
+
+Companion to [[file:issue-representation-spec.org][issue-representation-spec.org]] (description/title editing) and [[file:issue-comment-editing-spec.org][issue-comment-editing-spec.org]] (comment editing). All three share the conflict gate this doc proposes to extend.
+
+* Problem
+
+v1's conflict handling is detect / refuse / message: when a description, title, or comment changed both locally and on Linear since the last fetch, the push is refused and a message tells the user to refresh. That protects the remote, but it leaves the user stuck. The only way forward is a manual refresh — which replaces the subtree and *discards the local edit*. So the safe-by-default behavior has a data-loss trap one keystroke away, and no in-Emacs path to actually reconcile the two versions.
+
+Craig's direction (2026-05-24): keep it simple — offer use-local, use-remote, or rewrite-in-an-Emacs-buffer. Error messages must be descriptive. And there must be a way through that never silently discards the user's input, because that counts as data loss.
+
+* Current state
+
+- =pearl--sync-decision= (=pearl.el:~1682=) returns =:noop= / =:push= / =:conflict= from the three-way hash compare (local-rendered vs last-fetched vs current-remote).
+- =pearl-sync-current-issue=, =-sync-current-issue-title=, and =pearl-edit-current-comment= all =pcase= on that and, for =:conflict=, just =message= and stop.
+- =refresh-current-issue= has a dirty-buffer guard that refuses to refresh when the body has unpushed edits — so a refresh can't clobber silently *today*, but it also can't help resolve; the user has to throw away their edit to move on.
+
+* Proposed design
+
+On =:conflict=, instead of only refusing, prompt the user to choose a resolution. One shared helper drives all three call sites (description, title, comment) so the behavior is identical everywhere.
+
+** The resolution prompt
+
+=completing-read= (or a transient) with three choices, each with a descriptive label:
+
+1. *Use local* — push my version, overwriting the remote. Advances the stored hash to the local text.
+2. *Use remote* — discard my local edit and take Linear's current version. **Guarded against data loss** (see below): the local text is stashed before it's replaced.
+3. *Rewrite in a buffer* — open a reconciliation buffer showing both versions; the user produces the merged text and pushes that.
+
+A fourth implicit option is always cancel (=C-g=) — leaves everything untouched, same as today's refuse.
+
+** No data loss — the hard requirement
+
+"Use remote" and "rewrite" both risk throwing away what the user typed. Before either path replaces the local text, stash it so it's always recoverable:
+
+- Push the local version onto the =kill-ring= (so =yank= brings it back), and
+- write it to a dedicated =*pearl-conflict-backup*= buffer with a heading naming the issue/field and timestamp.
+
+The stash happens unconditionally on any destructive resolution. The message after "use remote" says where the old text went ("your local version is on the kill-ring and in =*pearl-conflict-backup*=").
+
+** The rewrite-in-a-buffer flow
+
+Open a reconciliation buffer prefilled so the user can see and edit both sides. Chosen mechanism (decision 1): *smerge*. Write the two versions as a =<<<<<<< LOCAL / ======= / >>>>>>> REMOTE= conflict and drop the user into =smerge-mode=, so =smerge-keep-current= / =-other= / =-all= and the rest work without custom keys. A short banner names the push/abort keys. (Considered and rejected: a plain two-section buffer — simpler but reinvents conflict navigation; and =ediff= — too heavy for a one-field reconcile.)
+
+On finish, the reconciled text (markers resolved) is pushed via the same =--update-*= path, and the stored hash advances to it.
+
+** Descriptive errors
+
+The conflict prompt and messages name specifics: the field (description / title / comment), the issue identifier, that both sides changed since the last fetch, and the remote's =updatedAt= so the user knows how stale their copy is. No bare "conflict detected".
+
+* Proposed v1 decisions (this feature)
+
+1. One shared resolution helper across description, title, and comment.
+2. Three resolutions plus cancel: use-local, use-remote, rewrite-in-buffer.
+3. Any destructive resolution stashes the local text to the kill-ring *and* a backup buffer first — never discard input.
+4. Messages and the prompt are field- and issue-specific.
+
+* vNext / out of scope
+
+- Field-level 3-way auto-merge (only the changed lines).
+- Conflict resolution for the drawer fields (state/priority/assignee/labels) — those are command-set, not free-text, so they don't have the same merge problem.
+
+* Decisions (Craig, 2026-05-24)
+
+1. *Rewrite-buffer mechanism*: =smerge=. Write the two versions as a =<<<<<<< / ======= / >>>>>>>= conflict and drop the user into =smerge-mode=; the =smerge-keep-*= commands work out of the box and the UX matches git muscle memory. No heavy dependency.
+2. *Stash location*: kill-ring + a =*pearl-conflict-backup*= buffer. In-memory recovery (yank, or read the named buffer); no file-backup layer in v1.
+3. *Default resolution on RET*: cancel. A bare =RET= at the prompt leaves everything untouched, the same as today's refuse — the safest default.
+4. *"Use remote" guard scope*: yes. =refresh-current-issue= adopts the same stash-before-replace guarantee, so no refresh path can lose an unpushed edit. (The merge refresh already keeps dirty subtrees rather than overwriting; this hardens the single-issue refresh, which today refuses on a dirty body — it will stash then proceed instead.)
diff --git a/docs/issue-query-spec.org b/docs/issue-query-spec.org
new file mode 100644
index 0000000..75dcc4b
--- /dev/null
+++ b/docs/issue-query-spec.org
@@ -0,0 +1,258 @@
+#+TITLE: pearl — Issue Query & Saved Reports Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-23
+#+STARTUP: showall
+
+* Status
+
+*DRAFT — review incorporated (2026-05-23), awaiting final go-ahead.* Design proposal; nothing in =pearl.el= has changed. The v1 scope is now decided (see [[*Agreed v1 decisions][Agreed v1 decisions]]); deferred items are in [[*vNext][vNext]]. Modifications/rejections of review recommendations are documented in [[*Review dispositions][Review dispositions]].
+
+Companion: [[file:issue-representation-spec.org][issue-representation-spec.org]] covers how an issue is *rendered and edited* in org once fetched. This doc covers *which* issues get fetched and *where* they land. They meet at the shared org→Linear write path and the single active-file output model.
+
+* Problem
+
+Today the package fetches one thing: issues assigned to me, optionally narrowed to a single project. Everything else — by status, by project regardless of assignee, by project + status, by label, priority, assignee, cycle — has no path, and there's no way to name a query and run it again.
+
+The ask, verbatim:
+
+- all open issues assigned to me
+- all open issues from a project
+- all open issues in a particular status
+- all open issues in a particular status in a particular project
+- many of these should be saved preferences
+- general enough to also cover labels, priorities, etc.
+
+A follow-up reframed the "saved preferences" half: Linear already has *Custom Views* (saved filters in the UI). Rather than invent a parallel local-only "reports" concept, read the user's existing views, run them from Emacs, and (later) push our own filters up. The answer (verified below) is largely yes.
+
+So this is one general filter model plus saved entry points on top of it — not a command per ask.
+
+* Current state (what exists today)
+
+Grounded in =pearl.el= (line refs drift):
+
+- *One query shape, hardcoded to "me".* All fetches go through =--get-issues-page-async= (l.368), sending =GetAssignedIssues= against =viewer { assignedIssues(...) }= (l.377-433). No use of the top-level =issues(filter:)= query.
+- *The only filter is project* — =filter: { project: { id: { eq: $projectId } } }=. No assignee/state/label/priority/team/cycle filter anywhere.
+- *State filtering is client-side and coupled to rendering.* =pearl-issues-state-mapping= (l.94) doubles as a global include-filter: only issues whose state is in the mapping get written (l.106-108). Adding a state mapping silently changes which issues appear.
+- *Pagination* is =first: 100= + =after:= cursor, capped at =pearl-max-issue-pages= (l.125, default 10).
+- *Output is single-file, single-title.* =--update-org-from-issues= (l.1201) writes =pearl-org-file-path= (l.87) with a hardcoded =#+title: Linear issues assigned to me= (l.1182). The sync hook matches the buffer against the hardcoded regex =linear\.org$= (l.956), independent of the defcustom.
+- *Name→ID resolution* exists for teams (=--get-team-id-by-name=, l.745, case-sensitive) and states (=--get-state-id-by-name=, l.709, per-team, case-insensitive). Projects have no name→ID helper. Labels, assignees, cycles: none.
+- *Saved filters / reports: none.*
+
+The fetch layer is the constraint: it can only ask Linear one question. Replacing that one hardcoded query with a general one is the spec's center of gravity.
+
+* Linear's API: what makes a general model possible
+
+The top-level =issues(filter: IssueFilter, first:, after:)= query plus the composable =IssueFilter= input type cover every item in the ask:
+
+| Ask | IssueFilter fragment |
+|---------------------------+----------------------------------------------------------|
+| assigned to me | =assignee: { isMe: { eq: true } }= |
+| assigned to a person | =assignee: { email: { eq: "x@y.com" } }= |
+| from a project | =project: { id: { eq: $projectId } }= |
+| in a status (by name) | =state: { name: { eq: "In Progress" } }= |
+| open (not done/cancelled) | =state: { type: { nin: ["completed", "canceled", "duplicate"] } }= |
+| in a team | =team: { key: { eq: "ENG" } }= |
+| with a label | =labels: { some: { name: { eq: "bug" } } }= |
+| by priority | =priority: { eq: 2 }= (0 none,1 urgent,2 high,3 med,4 low) |
+| in a cycle | =cycle: { id: { eq: $cycleId } }= |
+
+Two facts make "general enough" tractable: =IssueFilter= AND-s sibling fields and composes with =and=/=or= (so "open + status + project" is three sibling fields in one object); and workflow-state =type= (=triage / backlog / unstarted / started / completed / canceled / duplicate=, verified — seven values) is the workspace-independent "open" primitive, where "open" excludes =completed=, =canceled=, and =duplicate=. So one query (=issues(filter:)=) plus a Lisp→=IssueFilter= compiler covers it.
+
+* Linear Custom Views (verified against the published schema)
+
+Linear's product "Custom Views" are fully API-accessible. Verified facts (=linear/linear= master GraphQL schema):
+
+- *Read views:* =customViews(filter: CustomViewFilter, first:, after:, ...)= → =CustomViewConnection=; single via =customView(id)=. Each carries =name=, =description=, =team= (null = workspace-wide), =owner=, =creator=, =shared=, =icon=, =color=.
+- *Run a view server-side:* =CustomView.issues(filter: IssueFilter, first:, after:, ...)= resolves the view's own filter on Linear's side. We pass the view id and paginate — no local filter translation.
+- *Write views:* =customViewCreate/Update/Delete=; create input requires =name=, optional =filterData: IssueFilter=, =teamId=, etc. (vNext — see decisions.)
+- *No "default view" in the API.* "Default" is a UI concept only; a default must be a local preference naming a view.
+
+*Filter-format asymmetry (the crux).* On *write*, =CustomViewCreateInput.filterData= is typed =IssueFilter= — the same type Layer 1 compiles to. On *read*, =CustomView.filterData= is an opaque =JSONObject!=; the schema does *not* guarantee it round-trips as a re-usable =IssueFilter=. *Conclusion: never re-execute a fetched =filterData= locally.* Use the server-side =CustomView.issues= connection to run a view. (These findings are an implementation prerequisite to re-verify — see below.)
+
+* Agreed v1 decisions
+
+Settled in the 2026-05-23 review. These are no longer open.
+
+1. *Active-file output model.* One configured =pearl-org-file-path= shows *one active view/query at a time*. Running a different saved query or Custom View *replaces* the file contents after dirty-buffer/conflict checks. One Linear issue appears in exactly one place in the active view. (Resolves the output-model question, which gates user-visible multi-query commands — so it's decided up front, not deferred.)
+2. *Stable IDs in saved queries.* Saved local queries store stable Linear IDs; human names/keys are display metadata only. Interactive prompts show names; the compiled query executes by ID wherever the API supports it.
+3. *=pearl-list-issues= means "my open issues"* — =(:assignee :me :open t)=. No back-compat constraint (no users yet); this is the cleaner default.
+4. *Local saved queries are AND-only in v1.* OR is vNext; users needing OR create a Linear Custom View and run it from Emacs.
+5. *Sort/order in the query model.* Local saved queries support explicit =:sort= and =:order=, defaulting to =updated= / =desc=. Server-side ordering is limited to Linear's public =orderBy: PaginationOrderBy= — =createdAt= or =updatedAt=, recency-descending, with no direction argument (verified against the schema; the richer per-field =sort: [IssueSortInput!]= arg is marked =[INTERNAL]= and unstable, so v1 avoids it). So =:sort updated= / =:sort created= map to =orderBy= server-side; any other sort field (priority, title, …) or an explicit ascending =:order= is a deterministic client-side sort after fetch (so refresh doesn't reorder headings into noise).
+6. *Custom Views are read-only/run-only in v1.* Create/update/delete and pushing local queries up as views are vNext.
+
+* Implementation prerequisites — schema verification (complete)
+
+Both the published-schema pass and the live run are done, so this prerequisite is cleared.
+
+*Published-schema pass* (2026-05-23, against =linear/linear= master =schema.graphql=). Confirmed:
+
+- =issues(filter:)= takes every planned fragment; =IssueFilter= field/sub-filter names check out — assignee.isMe/email, state.name/type, project.id, team.key, labels.some/every (*no* =none=), priority =NullableNumberComparator= (eq/in/nin), cycle.id, and =and=/=or=.
+- Workflow-state =type= values: triage/backlog/unstarted/started/completed/canceled/*duplicate* (seven, not six).
+- Issues ordering is =orderBy: PaginationOrderBy= = createdAt/updatedAt only (see decision 5; the per-field =sort= arg is =[INTERNAL]=).
+- =CustomView.issues= → =CustomViewConnection= (nodes/pageInfo). =commentCreate(input: CommentCreateInput!)= → =CommentPayload= (comment/success/lastSyncId), input body+issueId(+parentId), all input fields nullable. =Comment.user= is *nullable* (bot/integration comments — see the representation spec).
+
+*Live run* (2026-05-23, deepsat workspace, via the package's own GraphQL layer with the key from =.authinfo.gpg=). Confirmed: =customViews= returns both shared *and* personal views for the key (6 shared + 1 personal); =issues(filter:)= with =assignee.isMe= + =state.type nin [completed,canceled,duplicate]= + =orderBy: updatedAt= returns the right open issues; =customView.issues= runs a view's filter server-side; comment read works; =commentCreate= on a test issue succeeds (test comment deleted after). The committed fixtures in =tests/testutil-fixtures.el= stay *synthetic* — real workspace data doesn't belong in a public repo — and were confirmed to match the live shapes. =CustomViewCreateInput.filterData= = =IssueFilter= stays unverified-by-use until view-write lands (vNext).
+
+* Proposed design
+
+** Layer 1 — the filter DSL (+ validation)
+
+*Authoring form* (convenient, names allowed; used for ad-hoc filters and hand-written queries). Each key optional; present keys AND-ed:
+
+#+begin_src elisp
+(:assignee :me ; :me | "email@addr" | nil
+ :open t ; t => state.type nin [completed,canceled,duplicate]
+ :state "In Progress" ; state name (needs team context) or :state-type
+ :state-type ("started" "unstarted") ; direct workflow-state type control
+ :project "Foo" ; project name (needs team context) or id
+ :team "ENG" ; team key or name
+ :labels ("bug" "p1") ; label names -> labels.some
+ :priority high ; symbol (none/urgent/high/medium/low) or 0-4
+ :cycle "Cycle 12" ; id, current/upcoming symbol, or team+number/name
+ :sort updated :order desc)
+#+end_src
+
+*Stored form* (saved queries): the resolved-to-IDs filter plus display metadata plus sort/order. The interactive builder resolves names→IDs at save time; the stored query executes by ID.
+
+*Selector semantics* (names are ambiguous — projects/labels/states/cycles can collide across teams):
+
+- =:team= — key or ID; ID internally.
+- =:project= — ID, or =(team . name)= for disambiguation.
+- =:state= — state type, or state ID/name *with team context*.
+- =:labels= — names only when team/project context removes ambiguity; otherwise prompt on multiple matches.
+- =:cycle= — ID, =current=/=upcoming= symbols, or team + cycle number/name.
+- =:open= and explicit =:state=/=:state-type= — if both set, the explicit state wins (it's more specific). =:open t= ≡ =type nin [completed,canceled,duplicate]= ≡ type in =triage/backlog/unstarted/started=.
+
+*Validation.* =pearl--validate-issue-filter= runs before compilation: rejects unknown keys, bad priority symbols, incompatible combinations, ambiguous fields lacking team context, empty strings, unsupported value shapes — with clear error messages (tested). A plist silently accepts typos; validation is what makes a user-facing saved-query defcustom safe.
+
+*Compiler.* =pearl--build-issue-filter (plist)= → the GraphQL =filter:= object, via small pure predicate helpers (=--eq=, =--nin=, =--some=, =--compile-priority=, =--compile-state-filter=), each unit-tested. Adding a dimension is a clause here, not a new command.
+
+** Layer 2 — general fetch over a normalized pager
+
+A single =pearl--page-issues= helper accepts a (query-builder . extractor) pair and returns *normalized* issue objects, owning the page cap, vector→list coercion, progress messages, and partial-error behavior in one place. Two callers:
+
+- =--query-issues-async (filter)= → top-level =issues(filter:)=.
+- =--query-view-async (view-id)= → =customView(id) { issues(...) }= (server applies the view's filter).
+
+The existing assigned-issues fetch becomes the first caller (=filter = {assignee:{isMe:{eq:true}}}=), collapsing the two hardcoded query variants.
+
+*Error shape.* Internal callbacks distinguish *no results* / *request failed* / *invalid filter* rather than collapsing all to =nil=; user commands collapse them to messages. (V1 minimum: enough to tell an empty result from a failure.)
+
+** Layer 3 — saved reports (Linear views first, local queries as complement)
+
+*Read side (main path).* =pearl-run-view= does =completing-read= over the user's =customViews= (cached), then fetches via the view primitive. "Run one of my saved Linear reports from Emacs" with zero local config.
+
+*Local saved queries (complement).* A defcustom of named filters for ad-hoc / Emacs-only reports, storing IDs + display metadata + sort/order (Agreed decisions 2, 5):
+
+#+begin_src elisp
+(defcustom pearl-saved-queries
+ '(("My open work"
+ :filter (:assignee :me :open t) :sort updated :order desc))
+ "Named local issue queries. Stored form keeps resolved IDs;
+display names are metadata. AND-only in v1; use a Linear Custom
+View for OR logic."
+ ...)
+#+end_src
+
+*Default report.* No API field, so a local =pearl-default-view= names a view (or saved query) run by the bare zero-arg command. (See [[*Review dispositions][Review dispositions]] on why a separate =default-issue-filter= is *not* added.)
+
+** State mapping vs filter — break the coupling
+
+Split the two jobs =issues-state-mapping= conflates today:
+
+- =pearl-state-to-todo-mapping= — render/sync Linear state ↔ org TODO keyword. Rendering only.
+- *Query filters* — inclusion/exclusion (=:open=, =:state=, =:state-type=). A filter, not a mapping.
+
+So adding a state-to-TODO mapping no longer changes which issues appear.
+
+** Output model — concrete
+
+One active file (=pearl-org-file-path=). Running a view/query replaces its contents after the dirty-buffer guard (and the representation spec's conflict check). The *file header* records the active source so refresh re-runs it without asking:
+
+- query/view name,
+- run timestamp,
+- filter summary,
+- issue count,
+- truncation warning if the page cap was hit (also =message='d),
+- source: local query vs Linear custom view.
+
+This makes reports self-describing and bug reports legible. The sync hook must recognize the configured =org-file-path=, not just =linear\.org$=.
+
+** Orientation & refresh commands
+
+- =pearl-refresh-current-view= — re-run the active source from the header.
+- =pearl-refresh-current-issue= — re-fetch the issue at point.
+- =pearl-open-current-view-in-linear= — if the source view has a URL.
+
+** Caching
+
+Caches for teams/states/projects/labels/views power both filters and interactive completion. V1 cache control:
+
+- =pearl-clear-cache= command,
+- a force-refresh argument on interactive selectors,
+- cache keys that include team ID where relevant.
+
+(Automatic TTL is vNext — see [[*Review dispositions][Review dispositions]].)
+
+** Layer 4 — commands
+
+- =pearl-list-issues= — zero-arg "my open issues" (Agreed 3), over Layer 2.
+- =pearl-run-view= — =completing-read= over Linear custom views; run server-side. Main saved-report path.
+- =pearl-run-saved-query= — pick a local saved query, run it.
+- =pearl-list-issues-filtered= — build an ad-hoc filter interactively; *complete from fetched* teams/projects/states/labels/cycles (not free text) to avoid typo'd-filter empty-result confusion; optionally save as a local query.
+- =pearl-list-issues-by-project= — keep; reimplement as a thin =(:project X :open t)= call.
+
+(A transient menu is a separate todo task and the natural front door once these exist.)
+
+* Phased implementation
+
+1. *Layer 1 + validation + tests.* Pure =--build-issue-filter= + =--validate-issue-filter= + predicate helpers. Normal/Boundary/Error + pairwise over dimension combinations. No API. Lands green.
+2. *Layer 2a — normalized pager + =--query-issues-async=.* Reimplement the assigned-issues fetch over it; characterization test proves =list-issues= still works, then flip it to "my open issues" (Agreed 3).
+3. *State-mapping/filter split* + project name→ID helper + =list-issues-filtered= (ad-hoc, complete-from-fetched).
+4. *Active-file output model* — header metadata, refresh-current-view, sync-hook recognizes the configured path. (Decided up front, implemented here because everything user-visible depends on it.)
+5. *Layer 2b + view read* — =--query-view-async=, =customViews= listing/cache, =run-view=, default-view preference.
+6. *Local saved queries* defcustom + =run-saved-query= + =:sort=/=:order=.
+
+vNext (gated, not in v1): view writes, OR DSL, automatic TTL, per-query/multi-view files.
+
+* Test strategy
+
+*Pure unit (first):* valid fragments for assignee me/email, open, state name/type, project ID, team key/ID, labels, priority, cycle; AND composition; =:state= vs =:open= precedence; bad keys / unresolvable names raise clear errors; priority symbol/number normalization; AND-only enforced (OR documented unsupported locally).
+
+*Query/pagination (request stubs):* top-level =issues(filter:)= uses the compiled variables; pagination follows =hasNextPage=/=endCursor=; page cap reports truncation; a partial error does not masquerade as an empty success; =customView.issues= extracts the same normalized shape.
+
+*Command/output:* =list-issues= = my open issues; =list-issues-by-project= is a thin general-query caller; running a saved query/view replaces the active file with accurate header metadata; sync hook recognizes the configured =org-file-path=; a dirty active file is not overwritten.
+
+*Fixtures:* small representative JSON for =issues=, =customViews=, =customView.issues=; normalize vectors / =null= / missing optional fields consistently.
+
+* Relationship to existing todo.org tasks
+
+Supersedes / absorbs three open feature tasks once approved — fold them into the phased plan rather than tracking separately:
+
+- =More issue filters (assignee, label, state, cycle)= — this *is* that task, generalized.
+- =Fetch scope beyond assigned issues= — Layer 2 + the =:assignee= dimension.
+- =list-issues-by-project= — a thin caller of the general path.
+
+* Open decisions
+
+None blocking v1 — the six agreed decisions resolved them. Remaining judgment calls are implementation-level (exact ad-hoc prompt flow, header wording) and don't gate the start.
+
+* vNext
+
+- OR support in the local saved-query DSL.
+- Interactive sort/order changes (command/menu).
+- Sync default sort/order back to Linear Custom Views if the API supports it.
+- Create/update/delete Custom Views from Emacs (workspace-mutating; explicit confirmation + shared/personal prompt).
+- Optional per-query files or multi-view files — only with demonstrated need *and* a designed duplicate-issue semantics.
+- Automatic cache TTL.
+- Batch/staged prefetch for interactive prompts (first team choice scopes and fetches the rest) — a perf refinement; v1 can fetch on demand.
+
+* Review dispositions
+
+All review recommendations were accepted and incorporated above except the following, modified with reasons:
+
+1. *Cache TTL defcustom → modified (deferred to vNext).* The review recommended an optional TTL defcustom alongside =clear-cache= and force-refresh. For a single-user tool, an automatic TTL adds invalidation complexity (stale-while-revalidate semantics, per-cache tuning) with little benefit over an explicit force-refresh. V1 ships =clear-cache= + a force-refresh arg on selectors; TTL is listed in vNext if a real need appears.
+2. *Separate =pearl-default-issue-filter= defcustom → rejected.* The review floated it ("possibly") as the default for the bare command. With Agreed decision 3 fixing =list-issues= to "my open issues" and =pearl-default-view= covering a user-chosen default report, a third default-filter knob is redundant surface area. The two existing mechanisms cover the need.
+3. *Structured error propagation → accepted but scoped.* Adopted as a v1 design principle (distinguish no-results / failure / invalid-filter at the internal boundary), but not a full callback-protocol refactor — v1 implements the minimum needed to keep "empty" and "failed" distinct, leaving a richer error type for later if the command surface grows.
+
+Everything else — active-file output model, ID-based selectors, state-mapping/filter split, filter validation, sort/order, schema-prerequisite checklist, normalized pager, GraphQL predicate helpers, self-describing headers, refresh/orientation commands, complete-from-fetched prompts, server-side filtering, store-IDs-in-properties, and the full test strategy — was accepted as written.
diff --git a/docs/issue-representation-spec.org b/docs/issue-representation-spec.org
new file mode 100644
index 0000000..912cee9
--- /dev/null
+++ b/docs/issue-representation-spec.org
@@ -0,0 +1,230 @@
+#+TITLE: pearl — Issue Org Representation & Editing Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-23
+#+STARTUP: showall
+
+* Status
+
+*DRAFT — review incorporated (2026-05-23), awaiting final go-ahead.* Design proposal; nothing in =pearl.el= has changed. v1 scope is decided (see [[*Agreed v1 decisions][Agreed v1 decisions]]); deferred items in [[*vNext][vNext]]; modifications/rejections in [[*Review dispositions][Review dispositions]].
+
+Companion to [[file:issue-query-spec.org][issue-query-spec.org]] (which covers *fetching/filtering*). This doc covers how an issue is *rendered and edited* once it's in org. They share the org→Linear write path and the single active-file output model.
+
+* Problem
+
+Open a fetched issue in org and the body below the drawer is empty — and with =org-tidy= folding the drawer, the whole entry looks blank. The one piece of free-text the issue has (its description) isn't missing; it's misfiled into a *property*. Users can't tell what they're allowed to edit.
+
+Grounded in =pearl--format-issue-as-org-entry= (=pearl.el:1114-1173=): the description is written into a =:DESCRIPTION: |= property as 2-space-indented lines (=l.1154-1158=), inside the drawer; the body after =:END:= (=l.1171=) is empty; =org-tidy= then hides the drawer and the entry reads as blank. That's the root of "I opened the task and there's nothing there / I'm not sure what I can edit."
+
+* Current rendering (what exists today)
+
+Each issue is a =***= heading =*** <TODO-state> <priority> <title>= (=l.1146=) plus a drawer carrying =:ID:= (Linear UUID), =:ID-LINEAR:= (the =ENG-123= identifier), =:TEAM:=, =:DESCRIPTION:= (the misfiled body), =:PRIORITY:=, =:LABELS:=, =:PROJECT:=, =:LINK:=, =:PROJECT-ID:= (=l.1148-1171=). Nothing below the drawer. The fetch query pulls =description= (=l.1119=) but *not* comments. State sync resolves team name → ID by network lookup each time (slow, fragile on rename/collision). The renderer strips =[ ]= from titles (=l.1145=) — existing lossy title behavior.
+
+* Agreed v1 decisions
+
+Settled in the 2026-05-23 review.
+
+1. *The org issue body is entirely Linear-owned in v1.* No local-only notes area. The active org file is a synchronized representation of Linear, not a mixed local/remote workspace.
+2. *Fetched comments are remote-owned display content.* Users can *add* comments; editing existing comments is vNext (and then only comments authored by the current Linear user, matching Linear's permissions).
+3. *New entries use only namespaced =LINEAR-*= properties.* No compatibility layer for the old =:ID:= / =:ID-LINEAR:= shape (no users yet).
+4. *Description sync starts as an explicit command only.* Automatic save-triggered description sync is vNext, after no-op detection and conflict handling are proven.
+5. *V1 conflict handling is detect / refuse / message.* Interactive diff-merge or local/remote choice is vNext.
+
+* Content ownership and refresh semantics
+
+The hard part isn't moving the description — it's distinguishing machine-owned fetched content from user edits once refresh, comments, and sync coexist. v1 makes this simple by fiat (decision 1: the whole body is Linear-owned), but the layout and refresh model still have to be explicit.
+
+** Generated entry layout
+
+#+begin_src org
+*** TODO [#B] ENG-123 Title
+:PROPERTIES:
+:LINEAR-ID: <uuid>
+:LINEAR-IDENTIFIER: ENG-123
+:LINEAR-URL: https://linear.app/.../ENG-123
+:LINEAR-TEAM-ID: <id>
+:LINEAR-TEAM-NAME: ENG
+:LINEAR-PROJECT-ID: <id>
+:LINEAR-PROJECT-NAME: Foo
+:LINEAR-STATE-ID: <id>
+:LINEAR-STATE-NAME: In Progress
+:LINEAR-ASSIGNEE-ID: <id>
+:LINEAR-ASSIGNEE-NAME: Craig
+:LINEAR-LABELS: [bug, p1]
+:LINEAR-DESC-SHA256: <hash of last-fetched markdown>
+:LINEAR-DESC-UPDATED-AT: <remote timestamp>
+:END:
+
+Description text managed by Linear (org-rendered).
+
+**** Comments
+***** <author> — <timestamp>
+comment body
+#+end_src
+
+*Store IDs and display names separately* for team, project, assignee, state, labels (and later cycle). Commands display names; they mutate by ID. This kills the per-render network name-lookup.
+
+*Provenance for the description* lives as a *hash + remote timestamp* in properties — not the full raw markdown. A large multiline markdown property is awkward in org and bad with folding. When the sync/no-op check needs the exact last-fetched markdown, fetch current remote markdown before deciding, or keep it in an internal cache keyed by =LINEAR-ID=. (See [[*Conflict handling][Conflict handling]].)
+
+** Refresh model — merge by ID, reconciled with the active-file output model
+
+The query spec's output model says *switching to a different view/query replaces the active file*. This spec's refresh says *don't wholesale-rewrite*. Both hold, for different actions:
+
+- *Switching source* (run a different view/query) → the issue set changes; replace the file contents after the dirty-buffer + conflict checks. One issue appears in one place.
+- *Refreshing the same source* (=refresh-current-view=, =refresh-current-issue=) → *merge by =LINEAR-ID===: update each existing issue subtree in place, add new matches, drop issues no longer in the result. Per subtree, run the conflict check before overwriting a description that was edited locally but not yet pushed.
+
+A wholesale rewrite on same-source refresh would clobber un-pushed description edits; merge-by-ID + per-subtree conflict check is what protects them.
+
+* Proposed model — body is editable content, drawer is machine-managed metadata
+
+Organizing principle: the body holds what a human reads and writes (description, comments); the drawer holds structured fields commands manage. An =org-tidy= user edits body text + runs commands and never touches the drawer.
+
+** Description → body
+
+Render the description as the heading body (org-converted — see [[*Markdown vs org — the conversion question][conversion]]). Opening a task now shows its description; the org-tidy blank-entry problem disappears. The body is the editable region; an explicit command (decision 4) pushes edits back, behind the conflict gate.
+
+** Drawer = command-managed fields
+
+State (TODO keyword), priority, labels, project, assignee live in the drawer/heading and change via dedicated commands ("Set assignee, priority, labels" task), which resolve names→IDs. =org-tidy= users never need to open the drawer.
+
+** Comments as a body subtree
+
+Fetch comments (needs a query change — not pulled today) and render *oldest-first* as =****= → =*****= sub-headings (=<author> — <timestamp>=, body beneath), so the thread reads chronologically and "add comment" appends at the end. =pearl-add-comment= creates a new comment via =commentCreate= and inserts/refreshes the returned comment. Fetched comments are remote-owned (decision 2): editing an existing comment heading does *not* sync back in v1.
+
+*Comment shape (verified against the published schema).* =Issue.comments= → =CommentConnection= (nodes/pageInfo); each =Comment= has =body= (markdown — runs through the same conversion tier as the description), =createdAt=, and =user=. *=user= is nullable* — comments from integrations or bots have no user, carrying =botActor= / =externalUser= instead. The renderer must fall back to the bot/external actor name (or a literal like "(automation)") for the author rather than assuming a =user.name=. =commentCreate(input: CommentCreateInput!)= returns =CommentPayload= (=comment=, =success=); the input takes =body= + =issueId= (and optional =parentId=), with success checked the same way as issue creation before reporting.
+
+** Affordance + discoverable commands
+
+A one-line preamble note (body = description, edit + sync via command; Comments subtree = thread, add via command; fields = drawer, change via commands). But commands matter more than a note — expose discoverable ones that work from *anywhere inside an issue subtree*:
+
+=pearl-sync-current-issue=, =pearl-open-current-issue=, =pearl-add-comment=, =pearl-set-priority=, =pearl-set-assignee=, =pearl-refresh-current-issue=.
+
+** Sub-issues (later)
+
+Optional nested headings; out of scope for v1.
+
+* Markdown vs org — the conversion question
+
+Linear stores descriptions/comments as *markdown*; we want *org* in the body. The directions differ in difficulty.
+
+- *org → markdown (push):* =ox-md= is built in, but it is *not* round-trip-faithful for the subset Linear uses (see [[*ox-md rejected for push][ox-md rejected for push]]). Push is therefore a hand-rolled inverse of the fetch converter.
+- *markdown → org (fetch):* no built-in. The only place pandoc is tempting.
+
+** ox-md rejected for push
+
+The original recommendation was =org-export-string-as ... 'md= for the push direction. Empirical testing (2026-05-23) of =org→md(md→org(x))= over the conversion matrix showed *zero of nine samples round-trip cleanly*. =ox-md= injects a =# Table of Contents= header, inverts emphasis (org =*italic*= → md =**bold**=), *drops checkbox markers* (=- [x] done= → =- done=), converts fenced code to 4-space indented blocks (losing the language), and reindents lists.
+
+This breaks the conflict gate two ways: the no-op guard compares =hash(org→md(body))= against the stored =LINEAR-DESC-SHA256= (hash of the last-fetched markdown), so a lossy push makes *every* no-op sync look like an edit; and the lossy output would *corrupt content pushed back to Linear* (dropped checkboxes, lost code-fence languages). This is the same lossy-round-trip failure mode the spec already rejected pandoc for — it applies to =ox-md= too.
+
+The push converter (=pearl--org-to-md=) is therefore hand-rolled as the symmetric inverse of the fetch converter (=pearl--md-to-org=), which makes round-trips byte-stable for the supported subset. Owning both directions also keeps the conversion tier self-consistent. *Two documented lossy edges remain* (inherent to the fetch converter, not the push side): a markdown =# heading= renders to a bold line on fetch and stays a bold line on push (restoring =#= would fork the org outline); single-asterisk markdown italics are unsupported on fetch (only =_underscore_= italics convert).
+
+** Pandoc — pros/cons
+
+- *Pros:* full-fidelity bidirectional GFM↔org; one tool; battle-tested.
+- *Cons:* hard external-binary dependency (MELPA-hostile; users without it get broken sync); subprocess per conversion; *lossy round-trip* (pandoc reflows/normalizes → spurious diffs on no-op fetch/push); cross-platform/version drift.
+
+** Recommendation — pure-elisp default, pandoc optional
+
+Hand-roll *both* directions: push via =pearl--org-to-md= (the inverse of the fetch pass — see [[*ox-md rejected for push][ox-md rejected for push]]), fetch via the lightweight md→org pass. No dependency, byte-stable round-trips. Pandoc is an *optional* enhancement: if =(executable-find "pandoc")= and a defcustom opts in, route both directions through it. Detected, never required — MELPA-safe.
+
+** Conversion matrix (the testable contract)
+
+"High-frequency constructs" needs a precise, testable subset. Unsupported constructs are *preserved as literal text*, never emitted as malformed org.
+
+| Markdown | Org | Note |
+|----------+-----+------|
+| =**bold**= | =*bold*= | |
+| =*italic*= / =_italic_= | =/italic/= | underscores in identifiers must not trigger emphasis |
+| =`code`= | =~code~= | |
+| =```lang ... ```= | =#+begin_src lang ... #+end_src= | language preserved |
+| =- item= / =* item= | =- item= | |
+| =1. item= | =1. item= | |
+| =- [ ]= / =- [x]= | =- [ ]= / =- [X]= | checkboxes |
+| =[text](url)= | =[[url][text]]= | |
+| => quote= | =#+begin_quote ... #+end_quote= | |
+| =# Heading= | *bold line*, NOT an org heading | an org heading would fork the issue subtree and corrupt structure |
+| tables / HTML / footnotes | literal pass-through | preserved, not converted |
+
+The =# Heading= → bold-line rule is load-bearing: converting a markdown heading inside a description to a real org heading would split the issue's subtree.
+
+* Conflict handling
+
+The round-trip-drift guard is necessary but not sufficient — it prevents no-op churn; it doesn't define conflicts. Promote it from a note to a *phase gate on sync-back*. The sync command compares three things:
+
+- *last-fetched* Linear markdown (hash in =:LINEAR-DESC-SHA256:=),
+- *current org-rendered* markdown (re-render the body to md, hash it),
+- *current remote* markdown / =updatedAt= (fetch before pushing).
+
+Outcomes:
+
+- org == last-fetched → no local edit → *no API call* (no-op guard).
+- org changed, remote == last-fetched → clean push.
+- org changed *and* remote changed since last fetch → *conflict*: stop, refuse to push, message the user (decision 5). Resolution workflows (diff/merge, local/remote-wins) are vNext.
+
+* Parsing — org-element, not regex
+
+Current parsing assumes a level-3 heading with a drawer immediately after and walks lines/regex. Once bodies and comment subtrees exist, that's brittle (misread drawers, nested comment headings mistaken for issues). Spec an =org-element=-based parser: locate issue headings by the durable =:LINEAR-ID:= property, read properties via org APIs, treat depth structurally — never =^\*\*\*= regexes.
+
+* Internal representation
+
+Normalize API responses into internal plists/structs *before* rendering, so the renderer never sees whether Linear returned a vector, =null=, or an omitted field. Comments, assignees, cycles, and views multiply the missing/null/vector handling otherwise. Model boundaries (filter compilation, API transport, issue/comment models, org rendering, org parsing, sync orchestration, commands) stay as *logical* sections — see [[*Review dispositions][Review dispositions]] on keeping a single file.
+
+* Actions a user wants in the body space
+
+- *Edit the description* in place → explicit sync (push, behind the conflict gate).
+- *Read the comment thread* without leaving Emacs.
+- *Add a comment* → =commentCreate= (append a sub-heading).
+- (later) navigate sub-issues.
+
+Field changes (assignee/priority/labels/state) stay command-driven, not body edits.
+
+* Impact on existing todo.org tasks
+
+Gives concrete shape to three already-open feature tasks; implement them together:
+
+- =Sync title and description back to Linear= — description-in-body + explicit push. *Phase title sync separately* from description (its own last-fetched-title hash + conflict behavior; note the existing bracket-stripping lossiness). Keep TODO-keyword state sync as the only automatic heading mutation in the first body-editing phase.
+- =Add a comment to an issue from Emacs= — the comment subtree + =commentCreate=.
+- =Set assignee, priority, and labels from Emacs= — command-driven drawer fields (mutate by ID).
+
+Cross-cuts the query spec at the shared write path and the active-file/refresh model.
+
+* Phased implementation
+
+1. *Description → body (read-only) + namespaced properties.* Move description out of =:DESCRIPTION:= into the body; switch to =LINEAR-*= properties storing IDs + display names; provenance hash + timestamp. Characterization test of the old shape first, then the new render; confirm =org-tidy= no longer shows a blank entry.
+2. *org-element parser.* Locate by =:LINEAR-ID:=, structural depth; replaces regex parsing before subtrees land.
+3. *Conversion tier.* Hand-rolled org→md push (inverse of the fetch pass; =ox-md= rejected for lossy round-trips) + lightweight md→org fetch per the matrix; unit-test the matrix (Normal/Boundary/Error) and the no-op round-trip invariant.
+4. *Refresh = merge by ID* + per-subtree conflict check; reconcile with the active-file replace-on-switch model.
+5. *Description sync-back (explicit command)* behind the conflict gate (the round-trip guard is the phase gate). Title sync as a separate step.
+6. *Comments* — add to the fetch query; render oldest-first; =add-comment= via =commentCreate=.
+7. *Pandoc optional path* + the affordance line + discoverable commands.
+8. *(later)* sub-issues, comment editing, local notes, save-hook automation, interactive conflict resolution.
+
+* Test strategy
+
+*Characterization (before changing rendering):* old shape renders description in =:DESCRIPTION:= with empty body; dirty visiting buffer not overwritten; state sync uses only matching issue headings; current parser behavior with drawer placement.
+
+*Per phase:* description after =:END:= with no =:DESCRIPTION:= property; org-element parser extracts properties even with body text + comment subtrees; comments render with IDs/timestamps oldest-first; =add-comment= makes one mutation and inserts/refreshes the returned comment; no-op description sync makes *no* API call; local-edit + remote-unchanged pushes the expected markdown; local-edit + remote-changed refuses with a conflict message; unsupported markdown stays readable and doesn't corrupt org.
+
+*Golden rendering:* small, intentional string snapshots of representative issue entries.
+
+* Open decisions
+
+None blocking v1 — the five agreed decisions resolved the ownership, conflict, sync-trigger, comment-immutability, and property-naming questions. Remaining calls are implementation-level (exact converter edge handling, command key bindings).
+
+* vNext
+
+- Local-only notes under issues, if a clean ownership representation emerges.
+- Editing existing comments — only those authored by the current Linear user.
+- Automatic description sync on save (after no-op detection + conflict handling are proven).
+- Interactive conflict handling: diff/merge, local-wins, remote-wins, manual merge.
+- Read-only text properties on remote-owned regions (after the command UX exists).
+- Sub-issue rendering.
+
+* Review dispositions
+
+All review recommendations were accepted and incorporated above except the following, modified with reasons:
+
+1. *"Split representation from network/API code" into modules → modified.* Adopted the *logical* boundaries (filter compilation / transport / models / rendering / parsing / sync / commands) and the "normalize before rendering" discipline, but *kept a single file* for v1. The package is a single-file =pearl.el= aiming at MELPA, where single-file is a virtue; splitting into multiple files is a larger restructuring with its own review. Logical sections + pure helpers get the unit-testability the review wants without the file split. Revisit multi-file only if size forces it.
+
+2. *Read-only text properties on remote-owned regions → deferred (the review's own lighter recommendation).* v1 detects edits to remote-owned generated areas and warns/refuses to push rather than making regions buffer-read-only, which would frustrate org users and complicate tests. Hard read-only is in vNext.
+
+Everything else — Linear-owned body, namespaced =LINEAR-*= properties, IDs-with-display-names, hash+timestamp provenance (not raw-markdown-in-property), merge-by-ID refresh reconciled with active-file replace, conflict detect/refuse/message as a phase gate, explicit-command sync, separate title/description sync, org-element parsing, the conversion matrix, oldest-first read/add-only comments, normalized model objects, discoverable subtree commands, and the full test strategy — was accepted as written.
diff --git a/docs/issue-sort-order-spec.org b/docs/issue-sort-order-spec.org
new file mode 100644
index 0000000..f49e869
--- /dev/null
+++ b/docs/issue-sort-order-spec.org
@@ -0,0 +1,65 @@
+#+TITLE: pearl — Interactive Sort/Order Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+*DRAFT — design proposal; nothing in =pearl.el= has changed.* Open questions for Craig at the end.
+
+Companion to [[file:issue-query-spec.org][issue-query-spec.org]], which defines the =:sort= / =:order= the saved-query layer already supports. This doc covers changing the order of the *current* view interactively, without hand-editing a saved query.
+
+* Problem
+
+v1 supports =:sort= (=updated= / =priority= / =title=) and =:order= (=asc= / =desc=) on saved queries. But to change how the active file is ordered, the user has to edit =pearl-saved-queries= (or the source plist) by hand and re-run. There's no "sort this view by priority, descending" command. For a view you're actually looking at, that's the natural thing to want.
+
+* Current state
+
+- =pearl--sort-issues= (query spec) applies =:sort= / =:order= client-side at the render boundary, so a refresh always lays headings out the same way.
+- =:sort= = =priority= / =title= are client-side; =created= / =updated= map to the server =orderBy= (the only fields Linear's API orders on). The query spec documents this split.
+- The active file's =#+LINEAR-SOURCE:= header records the source plist, including any =:sort= / =:order=, so =refresh-current-view= reproduces the ordering.
+
+* Proposed design
+
+** The command
+
+=pearl-set-sort= (interactive), run in the active file:
+
+1. =completing-read= the sort key: =updated=, =created=, =priority=, =title=.
+2. =completing-read= (or a toggle) the order: =asc= / =desc=.
+3. Update the =:sort= / =:order= in the active file's recorded =#+LINEAR-SOURCE:=.
+4. Re-order the view (see below).
+
+A =pearl-toggle-sort-order= convenience command just flips =asc=/=desc= on the current sort and re-orders. Both go on the transient menu (a small "Sort" group, or under View).
+
+** Re-order in place vs refetch
+
+The split matters for whether a sort change needs the network:
+
+- *Client-side sorts* (=priority=, =title=): the issues are already in the buffer. Re-sort in place — reparse the issue subtrees, reorder them, rewrite. No refetch. Fast, works offline.
+- *Server-side sorts* (=created=, =updated=): the ordering comes from the server =orderBy=, and the fetch may have been truncated at the page cap, so the correct order needs a refetch with the new =orderBy=. Re-run the source (the =refresh-current-view= path) with the updated sort.
+
+So =set-sort= decides: client-side key → re-sort the buffer; server-side key → refetch. The command reports which it did.
+
+** Persistence
+
+The change updates the active file's =#+LINEAR-SOURCE:= so a later =refresh-current-view= keeps the new order. Whether it also writes back to a named saved query in =pearl-saved-queries= is open question 3 — my lean is no by default (the active file is the scratch view; saved queries are the durable definitions), with an explicit "save this ordering to the query" as a separate step.
+
+* Proposed v1 decisions (this feature)
+
+1. =pearl-set-sort= + =pearl-toggle-sort-order=, both on the transient menu.
+2. Client-side keys re-sort the buffer in place; server-side keys refetch.
+3. The change updates the active file's recorded source so refresh preserves it.
+4. Completion is over the known keys/orders, never free text.
+
+* vNext / out of scope
+
+- Multi-key sort (e.g. priority then updated).
+- Per-heading manual reordering that sticks across refresh.
+- Exposing the full Linear =orderBy= surface if the API later un-gates the =[INTERNAL]= per-field sort.
+
+* Open questions for Craig
+
+1. *Command vs transient-only*: a plain =M-x pearl-set-sort= with two completing-reads, or a dedicated transient sub-menu with one-key sort toggles (=p= priority, =u= updated, =t= title, =o= flip order)? The transient reads faster for a frequent action.
+2. *In-place re-sort fidelity*: re-sorting client-side means reparsing and rewriting issue subtrees in the buffer. Acceptable, or prefer always-refetch for simplicity even when a client-side sort wouldn't need it?
+3. *Write-back*: should changing the sort offer to persist it to the originating saved query, or only ever update the active file's header?
diff --git a/docs/labels-as-org-tags-spec.org b/docs/labels-as-org-tags-spec.org
new file mode 100644
index 0000000..a1bb413
--- /dev/null
+++ b/docs/labels-as-org-tags-spec.org
@@ -0,0 +1,81 @@
+#+TITLE: pearl — Render Linear Labels as Org Tags Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+PROPOSED — awaiting review. Implements the =todo.org= task "Org tags should reflect the issue's Linear labels" (filed 2026-05-24 after a hardcoded personal =#+filetags:= value was removed from the header writer in 952cfe7).
+
+* Problem
+
+Pearl used to stamp a hardcoded personal =#+filetags:= value on every fetched file — file-wide, unrelated to any issue. That's gone. What's missing is the useful behavior: an issue's Linear labels should appear as org tags *on that issue's heading*, so the org-native gestures work — filter by tag, build a tag agenda, sparse-tree on =:bug:=. Today labels live only in the =:LINEAR-LABELS:= drawer (=[bug, backend]=), which org's tag machinery can't see.
+
+* Current state
+
+- =pearl--format-issue-as-org-entry= renders the heading as =** <STATE> [#P] <title>= with no tags, and writes the labels into the =:LINEAR-LABELS:= drawer as =[name, name]=.
+- =pearl--normalize-issue= gives each issue =:labels= as a list of plists with =:name=.
+- =pearl--issue-title-at-point= already extracts the title with =(org-get-heading t t t t)= — the four flags strip the keyword, priority cookie, *tags*, and comment markers. So title sync is already tag-aware; adding heading tags will not corrupt the title round-trip.
+- =pearl-set-labels= changes an issue's labels (completing-read over team labels), pushes, and rewrites the =:LINEAR-LABELS:= drawer.
+
+* Proposed design
+
+** Tag slugify: label name → org tag
+
+A new pure helper =pearl--label-name-to-tag=. Org tags are restricted to =[[:alnum:]_@#%]= — notably *no hyphens or spaces* (unlike TODO keywords, which allow hyphens). So this slugify differs from the keyword one:
+
+- Downcase (tags are case-sensitive in Org; lowercase is the convention).
+- Replace each run of characters outside =[[:alnum:]_]= with a single =_=.
+- Trim leading/trailing =_=.
+
+Examples: =Bug= → =bug=, =Needs Review= → =needs_review=, =P1= → =p1=, =backend/api= → =backend_api=, =UI/UX= → =ui_ux=. A label that slugifies to empty is dropped.
+
+(Contrast with =pearl--state-name-to-keyword= from the workflow-states spec, which upcases and uses hyphens. Two different targets, two different slugify rules — keep them as separate, clearly-named helpers.)
+
+** Render tags on the issue heading
+
+=pearl--format-issue-as-org-entry= appends the issue's label tags to the heading line in org tag syntax:
+
+: ** TODO [#B] Fix the thing :bug:backend:
+
+Slugify each label name, de-duplicate (preserving order), and join as =:t1:t2:=. No labels → no tag string. The =:LINEAR-LABELS:= drawer stays as the canonical structured store (it holds the exact Linear names, which the tag form lossily slugifies); the heading tags are the derived, org-native view.
+
+Only *issue* headings get tags. The parent view heading and the Comments / individual-comment headings carry none.
+
+** Keep the drawer authoritative; tags are a view
+
+The =:LINEAR-LABELS:= drawer remains the source of truth for the issue's labels (it survives slugify collisions and preserves the display names). =pearl-set-labels= continues to push and rewrite the drawer, and additionally re-renders the heading's tag string so the two stay consistent after a label change.
+
+** v1 is render-only (fetch direction)
+
+Editing the heading's tags by hand does *not* push to Linear in v1. The way to change labels stays =pearl-set-labels= (which now updates both the drawer and the heading tags). Bidirectional sync — parse the heading's tags on save and reconcile them against Linear's label set — is deferred (see out of scope); it needs the same kind of conflict gate the description/title/comment syncs have, and label *creation* semantics (a tag with no matching Linear label) to be decided.
+
+* Files touched
+
+- =pearl.el=: new =pearl--label-name-to-tag=; =pearl--format-issue-as-org-entry= (append tag string to the heading); =pearl-set-labels= (re-render heading tags after a label change). The =:LINEAR-LABELS:= drawer line is unchanged.
+- Tests: new cases for =pearl--label-name-to-tag= (normal, multiword, punctuation, collision, empty); a render test (issue with labels → heading carries the slugified tags); a regression test that =pearl--issue-title-at-point= / title sync still returns the bare title with tags present; a =pearl-set-labels= test that the heading tag string updates.
+- =README.org=: note in the Fields / "active org file" section that labels render as heading tags; mention the drawer remains the structured store.
+
+* Test plan
+
+- *Tag slugify*: =Bug=→=bug=, =Needs Review=→=needs_review=, =UI/UX=→=ui_ux=, =P1=→=p1=, collision de-dup, empty-after-slug dropped.
+- *Render*: an issue with labels =("Bug" "Backend")= renders =** … :bug:backend:= and still carries =:LINEAR-LABELS: [Bug, Backend]= in the drawer.
+- *Title-sync regression*: with tags on the heading, =pearl--issue-title-at-point= returns the bare title (no tags, no keyword), so a no-op title sync still matches and a title edit still round-trips.
+- *set-labels*: after changing labels, the heading tag string reflects the new set (and the drawer too).
+
+* Migration
+
+Additive — no breaking change. Existing files gain heading tags on the next fetch. The removed =#+filetags= is already gone (952cfe7). No defcustom changes.
+
+* Open questions for review
+
+1. *Tag case.* Lowercase (=Bug= → =bug=) for org-tag convention, or preserve Linear's case (=Bug= → =Bug=)? Lowercase is my lean; it matches how most org users tag and avoids =Bug= vs =bug= duplication.
+2. *=#+TAGS:= declaration.* Should the file declare =#+TAGS:= with the union of label slugs (for tag-completion in the buffer), the way the workflow-states spec derives =#+TODO:=? Bonus, not required for the feature. My lean: defer to a follow-up to keep v1 focused.
+3. *Tag inheritance.* Org tag inheritance is on by default, so an issue's tags apply to its Comments subtree in agenda/inheritance contexts. Harmless (a comment "inheriting" =:bug:= rarely matters), but flagging. Disable inheritance for these files, or accept it? My lean: accept it.
+4. *Bidirectional sync* (edit heading tags → push labels) — confirm it's out of scope for v1.
+
+* vNext / out of scope
+
+- Bidirectional tag editing (heading tags → Linear labels) with a conflict gate and label-creation semantics.
+- =#+TAGS:= completion declaration.
+- Color/face mapping from Linear label colors to org tag faces.
diff --git a/docs/todo-keywords-from-workflow-states-spec.org b/docs/todo-keywords-from-workflow-states-spec.org
new file mode 100644
index 0000000..fc0a51c
--- /dev/null
+++ b/docs/todo-keywords-from-workflow-states-spec.org
@@ -0,0 +1,209 @@
+#+TITLE: pearl — Derive Org TODO Keywords from Linear Workflow States Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-05-24
+#+STARTUP: showall
+
+* Status
+
+READY — implementation-ready, awaiting Craig's final go. Reviews incorporated (2026-05-24, rounds 1–4); round 4 returned a Ready verdict with no blocking findings. Implements the =todo.org= task "Derive the org TODO keywords from the Linear workflow states". One implementation prerequisite remains (verify =WorkflowState.position=; see prerequisites).
+
+* Problem
+
+The generated file's =#+TODO:= line is fixed — either the hardcoded =TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE= or a copy of the user's global =org-todo-keywords= — and =pearl-state-to-todo-mapping= is a static six-entry default. Neither reflects a team's real Linear states (Dev Review, PM Acceptance, Icebox, Grooming, …).
+
+Org only cycles a heading to a keyword listed in the file's =#+TODO:=. So you cannot move a ticket to "Dev Review" by cycling its TODO keyword: the keyword isn't in the line, and there's no mapping entry for it. =pearl-set-state= already reaches any state (id-based, header-independent), but the keyword-cycle path — the natural org gesture — is stuck on the hardcoded six.
+
+The =#+TODO:= line therefore becomes *generated infrastructure* for the sync-back write path. If it is missing a rendered keyword, stale after a merge refresh, or ambiguous after slugification, org-native state changes silently stop being a trustworthy write path. The header-and-reverse-lookup contract below is written to that bar.
+
+* Current state
+
+- =pearl--build-org-content= writes =#+TODO:= from =org-todo-keywords= (or the hardcoded fallback). It's a pure function.
+- =pearl--map-linear-state-to-org= renders an issue's keyword by =assoc= on =pearl-state-to-todo-mapping= (fallback =TODO=).
+- =pearl--map-org-state-to-linear= resolves a cycled keyword back to a Linear state *name* by =rassoc= on the same mapping; =pearl--process-heading-at-point= then calls =pearl--update-issue-state-async= with that name + team id.
+- =pearl--get-todo-states-pattern= builds the full-file scan regex from the mapping's keywords (cached in =pearl-todo-states-pattern=).
+- =pearl--team-states= is a *synchronous, cached* accessor (blocks via =pearl--wait-for= on first fetch, then serves from =pearl--cache-states=). =pearl-set-state= already calls it synchronously from command context.
+- The team-states GraphQL query fetches =id name color= only. Issue queries already fetch state =type= (but not workflow-state =position=).
+- The same-source refresh path (=pearl--merge-query-result= → =pearl--merge-issues-into-buffer=) updates issue subtrees in place and only rewrites the run-at / count / truncation header lines via =pearl--update-source-header=. It does *not* rebuild the file, so it does not touch =#+TODO:= today.
+
+* Decisions
+
+Settled inputs for v1 (A1 / B2 / C-yes agreed with Craig 2026-05-24; the remainder resolved from review):
+
+- *A1 — union.* A file may hold issues from several teams. Build one =#+TODO:= line from the union of all involved teams' states, de-duplicated by slug.
+- *B2 — derived replaces.* The keyword is always the slugified Linear state name. =pearl-state-to-todo-mapping= is *removed*, not layered. One source of truth (Linear), an honest header, a deterministic round-trip (keyword = =slugify(name)= everywhere, no stored reverse map).
+- *C-yes — defer cross-team slug collisions.* Documented known limitation (see out of scope).
+- *Done-side types.* =completed=, =canceled=, *and* =duplicate= render after the =|=; =triage=, =backlog=, =unstarted=, =started= before it. Split by =type=, never by name.
+- *Header coverage guarantee.* Every keyword visible on a heading in the buffer must be declared in =#+TODO:= — that header powers org cycling and sync-back. The header is the slug-union of (a) every visible issue heading's state and (b) every team's full state set that was fetched successfully. (a) guarantees coverage even when a team's state fetch fails; (b) makes absent states cyclable when available. A failed team's only degradation is that you can't cycle to a state none of its visible headings is in. The hardcoded line is used only when there are no states at all. The "visible headings" set differs by path: a full rebuild reads the normalized issue list; a merge refresh reads the *final buffer* (so retained/skipped dirty subtrees are covered — see below).
+- *Merge coverage via final-buffer scan.* For merge refresh the header is rebuilt from the final displayed buffer (scan every Linear issue heading's current TODO keyword) unioned with the fetched team states — not from the fetched issue list alone, which omits retained dirty subtrees. The buffer scan subsumes the fetched issues (they're in the buffer) and directly validates the invariant users see.
+- *Issue-own / position-less ordering.* Full workflow states (from team-states) order by =position=; states drawn from headings or the issue list carry no workflow =position=, so they append in first-seen order within their active/done partition, de-duped by slug.
+- *Slugify is Unicode-aware and locale-independent.*
+- *Same-team slug collision.* The pure gather/derive layer returns collision metadata (the colliding slug + states); the render/sync layer logs it. The header de-dups; sync-back resolves the keyword to the *first state by =position=*. Returning the metadata keeps the lossy transform testable without capturing =message=.
+- *Unknown keyword behavior splits by path.* Interactive current-heading sync (an =org-todo= cycle) whose keyword resolves to no team state raises a =user-error= naming the keyword + team. The full-file save scan reports and *skips* the unknown heading and continues to the rest — one stale heading must not abort syncing the others or surface as an after-save-hook error.
+- *Store the state type in the drawer.* Render a =:LINEAR-STATE-TYPE:= drawer field (the issue query already fetches state =type=). The active/done side of a keyword is a function of =type=, and the merge final-buffer scan recovers a retained heading's keyword but not its type from name/id alone — so the type must travel with the heading. Classification on the merge scan: by the heading's own =:LINEAR-STATE-TYPE:= when present (deterministic); else (a legacy heading written before this field) preserve the keyword's current side from the buffer's parsed TODO config — done side if the keyword is in =org-done-keywords=, active otherwise; else (no parseable =#+TODO:= / keyword unknown to Org) default to the active side and log a warning naming the keyword and issue. Retained headings thus keep their old Org done/active semantics until a clean refresh re-derives them from Linear.
+
+* Proposed design
+
+** Slugify: state name → org keyword
+
+A new pure helper =pearl--state-name-to-keyword=:
+
+- Upcase (locale-independent — Emacs =upcase=, no locale-sensitive casing).
+- Replace each run of characters *not* matched by =[[:alnum:]]= with a single hyphen. =[[:alnum:]]= is Unicode-aware in Emacs, so accented and non-Latin letters are preserved rather than stripped.
+- Trim leading/trailing hyphens.
+- If the result is empty (an all-punctuation/symbol name), fall back to =TODO=.
+
+Expected outputs:
+
+| Input | Output |
+|--------------------+-------------------|
+| =Dev Review= | =DEV-REVIEW= |
+| =In Progress= | =IN-PROGRESS= |
+| =Todo= | =TODO= |
+| =PM Acceptance= | =PM-ACCEPTANCE= |
+| =Backlog (prioritized)= | =BACKLOG-PRIORITIZED= |
+| =Dev-Review= | =DEV-REVIEW= |
+| =Ångström= | =ÅNGSTRÖM= |
+| =!!!= | =TODO= (empty fallback) |
+
+Note =Dev Review= and =Dev-Review= both produce =DEV-REVIEW= — a same-team collision (handled below). The existing default mapping was effectively slugify already (=Todo=→=TODO=, =In Progress=→=IN-PROGRESS=, …), so slugify reproduces today's keywords for those states.
+
+** Same-team slug collisions
+
+When two states in one team slugify to the same keyword (=Dev Review= and =Dev-Review=), the =#+TODO:= line lists the keyword once (de-dup, first-seen by =position= wins its slot). Sync-back resolves that keyword to the *first state by =position=* and logs a one-line warning naming the colliding states, so the behavior is deterministic and visible. Cross-team collisions are deferred (out of scope) — sync still resolves correctly per the heading's own team, but the header can't distinguish them.
+
+** Derive the =#+TODO:= line
+
+A new pure helper =pearl--derive-todo-line= takes an ordered list of states (each =(:name :type :position)=) and returns the keyword string:
+
+- Partition by =type=: done-side = =completed=/=canceled=/=duplicate=; active-side = everything else.
+- Within each side, preserve the input order (the caller supplies states already ordered — see Multi-team ordering).
+- Slugify each name; de-duplicate by slug, preserving first-seen order.
+- Result: ="ACTIVE-1 ACTIVE-2 … | DONE-1 DONE-2 …"=.
+
+When the state list is empty, return the hardcoded =TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE= so the file is always valid.
+
+** Multi-team ordering
+
+=position= is meaningful within a team, not across teams. To keep the header deterministic:
+
+1. Teams are ordered by *first-seen order in the sorted issue list* (the issues are already sorted before render).
+2. Within each team, states are ordered by Linear =position=.
+3. The union concatenates teams in that order, then de-dups by slug (first-seen wins), then the derive-line partitions by type.
+
+So the header order is stable across runs regardless of hash/traversal order.
+
+** Gather the states (pipeline)
+
+Because =pearl--team-states= is synchronous-with-cache and =pearl-set-state= already calls it from command context, the render path gathers states synchronously without restructuring into async callbacks:
+
+1. After issues are normalized and sorted, collect the distinct =LINEAR-TEAM-ID= values in first-seen order.
+2. For each team, call =pearl--team-states= (cached after the first hit). Show a progress message while a fetch blocks; on a team's fetch failure, log it and continue (per the coverage guarantee).
+3. Build the union: every displayed issue's own state, plus the full states of each team that fetched. Order per Multi-team ordering, de-dup by slug.
+4. Hand the union to =pearl--build-org-content=.
+
+=pearl--build-org-content= stays pure: it gains a =states= argument (the ordered union list) and writes the derived =#+TODO:= via =pearl--derive-todo-line=. The async layer does the synchronous gather just before calling it. One synchronous, cached team-states fetch per distinct team per session — usually one or two; multi-team views do N bounded blocking fetches.
+
+The team-states query gains =type= and =position= (currently =id name color=), and the cache entry keeps them.
+
+** Render each issue's keyword
+
+=pearl--format-issue-as-org-entry= renders the heading keyword as =pearl--state-name-to-keyword(issue-state-name)= instead of =pearl--map-linear-state-to-org=. Safe because the header always includes each displayed issue's own state (coverage guarantee). It also writes a =:LINEAR-STATE-TYPE:= drawer field next to =:LINEAR-STATE-ID:= / =:LINEAR-STATE-NAME:=, so a later merge scan can classify a retained heading onto the correct side of the =|= from the heading itself (see the classification decision).
+
+** Generated header update on refresh
+
+The same-source merge refresh must keep the header honest: a refresh can add an issue from a team whose state keyword isn't yet declared, or surface a renamed/added/removed state — *and* it retains existing subtrees that the merge skips. =pearl--merge-issues-into-buffer= keeps a dirty existing subtree (unpushed body edits) without re-rendering it, and keeps a dirty issue that's absent from the refreshed result rather than dropping it. Those headings stay visible after the refresh, so their keywords must be declared too.
+
+A new helper =pearl--update-derived-todo-header= rewrites the =#+TODO:= line in place (creating it if absent). It derives from the *final displayed buffer*: scan every Linear issue heading (one carrying =LINEAR-ID=) for its current TODO keyword *and its =:LINEAR-STATE-TYPE:= drawer*, union those with the fetched team states (per the ordering rules), classify each onto the active/done side (by type when known; otherwise the fallback in the classification decision), and rewrite the line. =pearl--merge-query-result= calls it after the merge and the state gather, alongside =pearl--update-source-header=. Scanning the final buffer — rather than building from the fetched issue list — is what guarantees retained/skipped subtrees are covered, and it directly validates the invariant: every keyword visible in the buffer is declared on the correct side of the bar.
+
+** Sync-back: cycled keyword → Linear state
+
+=pearl--process-heading-at-point= resolves the cycled keyword via the heading's team rather than the removed mapping:
+
+1. Read the heading's TODO keyword + =LINEAR-TEAM-ID=.
+2. =pearl--team-states= (cached) for that team; find the state whose =slugify(name)= equals the keyword (first by =position= on a collision).
+3. If a state matches, push it via =pearl--update-issue-state-async= (unchanged; it resolves name → id per team).
+4. If *no* state matches (stale buffer keyword after a workflow change, or an old mapped keyword from a pre-upgrade file), the behavior depends on the path. Interactive current-heading sync (an =org-todo= cycle) raises a =user-error= naming the keyword + team and suggesting a refresh or =pearl-clear-cache=. The full-file save scan (=pearl-sync-org-to-linear=, non-=org-todo= path) reports the unknown heading (a =pearl--log= / message) and *skips* it, continuing to the rest — one stale heading must not abort the scan or fail an after-save hook. Neither path ever silently no-ops or pushes a wrong state.
+
+No persisted reverse map: the keyword is always =slugify(name)=, so the match recomputes from the team's live states.
+
+** The full-file scan pattern
+
+=pearl--get-todo-states-pattern= no longer builds from the mapping. The full-file sync scan (=pearl-sync-org-to-linear=, non-=org-todo= path) builds its keyword alternation from the buffer's own =org-todo-keywords-1= (what Org parsed from =#+TODO:=), so it matches whatever the derived header declared, with no stale cache. The =pearl-todo-states-pattern= / =pearl--todo-states-pattern-source= caches are removed with the mapping.
+
+** User-facing errors
+
+None of these fail silently: an unknown heading keyword on sync-back (=user-error= naming keyword + team), a missing =LINEAR-TEAM-ID= on a heading being synced, a team state fetch that fails during render (logged + progress/skip message), and a same-team slug collision (logged warning). Each names the offending value.
+
+* Implementation prerequisites
+
+- *Verify =WorkflowState.position=* against the current Linear schema or a live query before implementation. Issue queries fetch state =type= today but not workflow-state =position=. If =position= is unavailable, fall back to the order the API returns states in (still deterministic per fetch) and note it.
+- Confirm the Linear state =type= enum is =triage/backlog/unstarted/started/completed/canceled/duplicate= (already verified in =docs/issue-query-spec.org=).
+- Only =pearl--team-states= gains the =type=/=position= fields. =pearl-get-states-async= / =pearl-get-states= (used by creation flows) fetch =id name color= and are intentionally left unchanged — this feature doesn't depend on them. The two query shapes diverging is acceptable for v1; aligning them is optional follow-up cleanup.
+
+* Phased implementation plan
+
+Ordered so dependencies land first.
+
+1. *Pure core.* =pearl--state-name-to-keyword= and =pearl--derive-todo-line= + their tests (no I/O). Everything else depends on these.
+2. *Query + gather.* Add =type=/=position= to the team-states query (after the position verification); add the synchronous gather helper (distinct teams → union with coverage guarantee + multi-team ordering).
+3. *Full rebuild.* =pearl--build-org-content= takes =states=, writes the derived header; =pearl--format-issue-as-org-entry= renders via slugify and adds the =:LINEAR-STATE-TYPE:= drawer field. Assert the rebuilt header declares every rendered heading keyword.
+4. *Merge refresh.* =pearl--update-derived-todo-header= (final-buffer scan + active/done classification by the heading's =:LINEAR-STATE-TYPE:=, with the org-done-keywords fallback) wired into =pearl--merge-query-result=.
+5. *Sync-back.* Team-aware resolve in =pearl--process-heading-at-point= with unknown-keyword refusal; scan pattern from =org-todo-keywords-1=.
+6. *Remove the mapping.* Delete =pearl-state-to-todo-mapping=, =pearl--map-linear-state-to-org=, =pearl--map-org-state-to-linear=, =pearl-todo-states-pattern=, =pearl--todo-states-pattern-source=. Replace =test-pearl-mapping.el= with =test-pearl-keywords.el= (keep the regression class: a changed header/keyword set affects full-file scan with no stale cache).
+7. *Docs.* README state-mapping section + customization table; migration notes.
+
+* Test plan
+
+- =pearl--state-name-to-keyword=: ASCII names, punctuation, repeated punctuation, high-ASCII/accented, double-byte, combining characters, emoji/symbol-only → =TODO=, empty string, =Dev Review= vs =Dev-Review= collision, a real =Todo= alongside an empty-derived =TODO=.
+- =pearl--derive-todo-line=: active/done partition *including =duplicate= after the bar*, per-team =position= ordering, deterministic multi-team order, duplicate-slug first-wins, empty-states fallback.
+- *Full rebuild*: =build-org-content= with a state set emits the derived =#+TODO=; the header declares every rendered heading keyword; an issue in =Dev Review= renders the keyword =DEV-REVIEW= on its level-2 heading.
+- *Merge refresh*: a same-source refresh where a newly fetched issue introduces =DEV-REVIEW= updates the buffer's =#+TODO:= line (creates it if missing).
+- *Merge refresh — retained dirty subtree (absent from result)*: an old dirty issue in =QA-REVIEW= is gone from the refreshed result, is kept by the merge, and the rewritten =#+TODO:= still declares =QA-REVIEW=.
+- *Merge refresh — skipped dirty subtree (still in result)*: a dirty issue still present is skipped (not re-rendered), and the rewritten header still declares its current kept keyword even if it differs from the fetched issue's new state.
+- *Merge refresh — done-side classification of a type-less retained heading*: a retained dirty heading whose keyword was on the done side, whose team-state fetch fails and which lacks =:LINEAR-STATE-TYPE:= (legacy), keeps its keyword *after* the =|= via the org-done-keywords fallback. Same setup for an active retained heading keeps it *before* the bar. A missing/unparseable old header defaults the unknown keyword to active and logs the ambiguity.
+- *Drawer carries the type*: a freshly rendered issue's drawer includes =:LINEAR-STATE-TYPE:=, and a merge scan classifies it by that field without the fallback.
+- *Partial failure*: a multi-team result where one team's state fetch fails still declares every rendered keyword (issue-own states in the header) and renders without error; the two issue-own fallback states from the failed team order deterministically (first-seen within partition).
+- *Sync-back*: =DEV-REVIEW= resolves through the heading's team states; a same-team collision resolves to first-by-position.
+- *Unknown keyword by path*: interactive current-heading sync of an unknown keyword raises =user-error=; the full-file scan reports and skips it and still syncs the other headings.
+- *Collision metadata*: the pure gather/derive layer returns the colliding slug + states (asserted directly, no =message= capture).
+- *Migration/regression*: an old mapped keyword no longer in the derived header does not silently push a wrong state (interactive refuses; scan skips).
+- *Scan pattern*: the full-file scan matches a derived keyword present in the buffer's =#+TODO= with no stale-cache dependency.
+
+* Migration / breaking change
+
+Removing =pearl-state-to-todo-mapping= is a breaking change for anyone who set it. The package is pre-release (MELPA pending), so no deprecation cycle. The commit is =feat!:= with a =BREAKING CHANGE:= footer.
+
+*Upgrade path:* after upgrading, *refresh a Pearl file before cycling TODO keywords on it.* An old file's header and headings may use custom-mapped keywords that no longer resolve; cycling one of those now *refuses* (unknown-keyword =user-error=) rather than silently pushing a wrong state, so the safe move is to re-fetch the file so its header and keywords become the derived set. The README migration note must state this because the defcustom is going away.
+
+* Review dispositions
+
+Only modified or rejected recommendations, and decisions worth recording, are listed; everything else from the reviews (2026-05-24, rounds 1–4) was accepted as written and woven into the body above.
+
+** Round 4 (2026-05-24)
+
+Ready verdict — no blocking findings. The round-3 classification blocker is confirmed resolved (=:LINEAR-STATE-TYPE:= drawer + legacy fallback). The sole caveat, verifying =WorkflowState.position=, was already recorded as an implementation prerequisite. Tidied the one org-lint nit (a literal double-star =DEV-REVIEW= example in the test plan) the reviewer flagged as harmless.
+
+** Round 3 (2026-05-24)
+
+- *HP1 "active/done classification of type-less scanned headings" — modified.* The review's fallback (preserve the keyword's side from the buffer's parsed =org-done-keywords=) is a recovery run on every merge. Modified to stop losing the type at the source: render a =:LINEAR-STATE-TYPE:= drawer field (free — the issue query already fetches =type=) so a scanned heading classifies by its own recorded type deterministically. The review's org-done-keywords-side preservation is kept as the *fallback* for legacy headings lacking the field, with default-active-and-log as the last resort. Fully addresses the concern and removes the per-merge reparse for go-forward files.
+- *Open question 1 (preserve old side vs default-all-active) — resolved:* preserve the old side, primarily via the stored type, with the parsed-header fallback — not default-all-to-active.
+- MP1 (two state-fetch API shapes) accepted: extend =pearl--team-states= only; leave =pearl-get-states-async= unchanged for v1.
+
+** Round 2 (2026-05-24)
+
+- *HP1 "retained dirty subtrees in the header" — accepted, option (b).* The review offered two implementations: thread retained/skipped state metadata out of =pearl--merge-issues-into-buffer=, or scan the final buffer. Chose the final-buffer scan — it directly validates the invariant ("every keyword visible is declared"), subsumes the fetched issues, and avoids widening the merge helper's return contract.
+- *Open question 1 (scan: abort vs skip-and-continue) — resolved:* skip-and-continue with a report on the full-file scan; =user-error= only on interactive current-heading sync.
+- *Open question 2 (final-buffer scan vs returned metadata for merge coverage) — resolved:* final-buffer scan (the HP1 option-(b) choice).
+- MP1 (position-less issue-own ordering), MP2 (split unknown-keyword behavior), and MP3 (return collision metadata) accepted as written.
+
+** Round 1 (2026-05-24)
+
+- *HP3 "partial team-state fetch failure" — modified.* The review offered two rules: global hardcoded fallback on any failure, or fail the render and leave the file unchanged. Both discard information — the first drops real derived keywords for teams that succeeded; the second leaves the user with nothing. Adopted instead the *header coverage guarantee*: derive the header from the union of each displayed issue's own state (always available from the issue query) plus each successfully-fetched team's full states. This keeps every rendered keyword declared regardless of fetch outcome, and a failed team degrades only to "can't cycle to its absent states." The hardcoded line is reserved for the no-states-at-all case. The review's underlying safety requirement — "the render rule is only safe when the header contains that slug" — is met more completely this way.
+- *Review open question 1 (leave-unchanged vs conservative fallback) — resolved* by the HP3 modify above: neither; the coverage-guarantee union.
+- *Review open questions 2 and 3 — resolved as decisions:* slugify is Unicode-aware and locale-independent (Q2); same-team collisions resolve to first-by-position with a logged warning (Q3). Both now live in Decisions.
+
+* vNext / out of scope
+
+- *Cross-team slug collisions.* Two teams whose states slugify to the same keyword collapse to one keyword in a multi-team file; sync still resolves per the heading's own team, so the push is correct, but the header can't distinguish them. Disambiguation (team-prefixed keywords, per-team =#+TODO= sections) is deferred.
+- *Automatic workflow-state cache staleness.* States are cached for the session; a mid-session Linear workflow change needs =pearl-clear-cache=. A TTL/auto-invalidation is deferred.
+- *Label-color → tag-face mapping* and other presentation polish.
diff --git a/package-summary.md b/package-summary.md
new file mode 100644
index 0000000..894840d
--- /dev/null
+++ b/package-summary.md
@@ -0,0 +1,28 @@
+### Brief summary of what the package does
+
+pearl integrates Linear.app issue tracking with Emacs and org-mode. It fetches your issues — open issues, a project, an ad-hoc filter, a Linear Custom View, or a named saved query — 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 property drawer. You can edit a description, title, or your own comments and push them back with conflict-aware sync, set priority/state/assignee/labels by command, add comments, and create or delete issues, all without leaving Emacs. A transient menu (`M-x pearl-menu`) gathers every command.
+
+### Direct link to the package repository
+
+https://github.com/cjennings/pearl
+
+### Your association with the package
+
+I am the author and maintainer. pearl is based on and inspired by Gael Blanchemain's linear-emacs (GPL-3.0).
+
+### Relevant communications with the upstream package maintainer
+
+None needed — pearl is a renamed, independently maintained derivative of the GPL-3.0 linear-emacs.
+
+### Checklist
+
+<!-- Please confirm by replacing `[]` with `[x]`: -->
+
+- [x] The package is released under a [GPL-Compatible Free Software License](https://www.gnu.org/licenses/license-list.en.html#GPLCompatibleLicenses)
+- [] I've read [CONTRIBUTING.org](https://github.com/melpa/melpa/blob/master/CONTRIBUTING.org)
+- [x] I've used the latest version of [package-lint](https://github.com/purcell/package-lint) to check for packaging issues, and addressed its feedback
+- [x] My elisp byte-compiles cleanly
+- [x] I've used `M-x checkdoc` to check the package's documentation strings
+- [] I've built and installed the package using the instructions in [CONTRIBUTING.org](https://github.com/melpa/melpa/blob/master/CONTRIBUTING.org)
+
+<!-- After submitting, please fix any problems the CI reports. -->
diff --git a/pearl.el b/pearl.el
new file mode 100644
index 0000000..7e1b2cf
--- /dev/null
+++ b/pearl.el
@@ -0,0 +1,3527 @@
+;;; pearl.el --- Linear.app integration -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2025
+;; Author: Craig Jennings <c@cjennings.net>
+;; 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://github.com/cjennings/pearl
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; 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 saved query -- 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)
+
+;;; 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-state-to-todo-mapping
+ '(("Todo" . "TODO")
+ ("In Progress" . "IN-PROGRESS")
+ ("In Review" . "IN-REVIEW")
+ ("Backlog" . "BACKLOG")
+ ("Blocked" . "BLOCKED")
+ ("Done" . "DONE"))
+ "Mapping between Linear state names and Org TODO keywords, for rendering.
+Each element is a cons cell (LINEAR-STATE . ORG-STATE). It controls how a
+Linear state renders as an Org TODO keyword and how an Org keyword maps back
+on sync. It no longer decides which issues appear: inclusion is now a
+server-side query concern (see `pearl-list-issues'). A Linear state
+not listed here renders with the default TODO keyword."
+ :type '(alist :key-type string :value-type 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)
+
+(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)
+
+(defun pearl--hide-all-drawers ()
+ "Collapse every property drawer in the current buffer, across Org versions."
+ (cond ((fboundp 'org-fold-hide-drawer-all) (org-fold-hide-drawer-all))
+ ((fboundp 'org-cycle-hide-drawers) (org-cycle-hide-drawers 'all))))
+
+(defun pearl--restore-page-visibility ()
+ "Re-fold the whole current buffer to its `#+STARTUP' visibility and hide drawers.
+Used after a full repopulation (list / view / merge refresh) so the page does
+not sprawl open. A no-op when `pearl-fold-after-update' is nil."
+ (when pearl-fold-after-update
+ ;; A full in-place rebuild (Branch B / merge) leaves Org's parsed startup
+ ;; options stale -- the new `#+STARTUP:' text is in the buffer but
+ ;; `org-startup-folded' still holds the value read on first visit. Re-read
+ ;; the options so the fold honors the buffer's actual `#+STARTUP'.
+ (when (fboundp 'org-set-regexps-and-options) (org-set-regexps-and-options))
+ ;; `org-set-startup-visibility' was renamed in Org 9.6; funcall picks the
+ ;; available name without tripping the byte-compiler's obsoletion warning.
+ (funcall (if (fboundp 'org-cycle-set-startup-visibility)
+ 'org-cycle-set-startup-visibility
+ 'org-set-startup-visibility))
+ (pearl--hide-all-drawers)))
+
+(defvar pearl-todo-states-pattern nil
+ "Cached regex pattern for matching Org TODO states.
+This pattern is generated from `pearl-state-to-todo-mapping'.
+Use `pearl--get-todo-states-pattern' to get the pattern.")
+
+(defvar pearl--todo-states-pattern-source nil
+ "Mapping the cached pattern was built from.
+Holds the `pearl-state-to-todo-mapping' value used to build
+`pearl-todo-states-pattern', so the cache can be invalidated
+when the mapping changes.")
+
+;; Cache variables
+(defvar pearl--cache-issues nil
+ "Cache for issues.")
+
+(defvar pearl--cache-teams nil
+ "Cache for teams.")
+
+(defvar pearl--cache-states nil
+ "Cache of workflow states per team, an alist of (TEAM-ID . STATES).
+Populated on first state lookup; clear it to force a refresh.")
+
+(defvar pearl--cache-team-collections nil
+ "Cache of per-team collections for name->id resolution.
+An alist keyed by (KIND . TEAM-ID) where KIND is one of `projects',
+`labels', `members', `cycles'; the value is the list of nodes. Clear it (or
+pass a force argument) to refresh.")
+
+(defvar pearl--cache-views nil
+ "Cache of the workspace's Linear Custom Views (a list of node alists).
+Populated on first listing; clear it (or pass a force argument) to refresh.")
+
+(defvar pearl--cache-viewer nil
+ "Cached current-viewer plist (:id :name), fetched once per session.
+Backs the comment-edit permission check; clear it to force a refresh.")
+
+;; Progress tracking variables
+(defvar pearl--active-requests 0
+ "Number of currently active API requests.")
+
+;;; Core API Functions (Async-First Architecture)
+
+(defun pearl--headers ()
+ "Return headers for Linear API requests."
+ (unless pearl-api-key
+ (error "Linear API key not set. Use M-x customize-variable RET pearl-api-key"))
+
+ ;; For personal API keys, the format is: "Authorization: <API_KEY>"
+ ;; No "Bearer" prefix for personal API keys
+ `(("Content-Type" . "application/json")
+ ("Authorization" . ,pearl-api-key)))
+
+(defun pearl--log (format-string &rest args)
+ "Log message with FORMAT-STRING and ARGS if debug is enabled."
+ (when pearl-debug
+ (apply #'message (concat "[Linear] " format-string) args)))
+
+(defun pearl--progress (format-string &rest args)
+ "Show a progress message built from FORMAT-STRING and ARGS.
+Shown only when `pearl-progress-messages' is non-nil."
+ (when pearl-progress-messages
+ (apply #'message (concat "[Linear] " format-string) args)))
+
+(defun pearl--wait-for (predicate)
+ "Busy-wait until PREDICATE is non-nil or the request timeout elapses.
+Return the final value of PREDICATE: non-nil when it succeeded, nil when
+the wait timed out after `pearl-request-timeout' seconds. This
+keeps the synchronous wrappers from hanging Emacs when a callback never
+fires."
+ (let ((deadline (+ (float-time) pearl-request-timeout)))
+ (while (and (not (funcall predicate))
+ (< (float-time) deadline))
+ (sleep-for 0.1))
+ (funcall predicate)))
+
+(defun pearl--graphql-request-async (query &optional variables success-fn error-fn)
+ "Make an asynchronous GraphQL request to Linear API.
+QUERY is the GraphQL query string.
+VARIABLES is an optional alist of variables to include in the request.
+SUCCESS-FN is called with the response data on success.
+ERROR-FN is called with error information on failure.
+If SUCCESS-FN or ERROR-FN are not provided, default handlers will be used."
+ (pearl--log "Making async GraphQL request with query: %s" query)
+ (when variables
+ (pearl--log "Variables: %s" (prin1-to-string variables)))
+
+ (setq pearl--active-requests (1+ pearl--active-requests))
+
+ (unless success-fn
+ (setq success-fn (lambda (data)
+ (pearl--log "Request completed: %s" (prin1-to-string data)))))
+
+ (unless error-fn
+ (setq error-fn (lambda (error-thrown _response data)
+ (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)))))))
+ (pearl--log "Request payload: %s" request-data)
+
+ (request
+ pearl-graphql-url
+ :type "POST"
+ :headers (pearl--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))))))
+
+(defun pearl--graphql-request (query &optional variables)
+ "Synchronous wrapper for GraphQL requests (backward compatibility).
+QUERY is the GraphQL query string.
+VARIABLES is an optional alist of variables.
+Returns the response data or nil on error.
+This function blocks until the request completes."
+ (pearl--log "Making synchronous GraphQL request (backward compatibility mode)")
+ (let ((response nil)
+ (error-response nil)
+ (completed nil))
+
+ (pearl--graphql-request-async
+ query
+ variables
+ (lambda (data)
+ (setq response data)
+ (setq completed t))
+ (lambda (error-thrown _response _data)
+ (setq error-response error-thrown)
+ (setq completed t)))
+
+ ;; Legacy busy-wait: this wrapper predates `pearl--wait-for' and keeps its
+ ;; own hardcoded 30s bound rather than honoring `pearl-request-timeout'.
+ (let ((timeout 30)
+ (start-time (current-time)))
+ (while (and (not completed)
+ (< (float-time (time-subtract (current-time) start-time)) timeout))
+ (sleep-for 0.1)))
+
+ (if error-response
+ (progn
+ (message "Linear API error: %s" error-response)
+ nil)
+ response)))
+
+;;; Team Management (Async)
+
+(defun pearl-get-teams-async (&optional callback)
+ "Asynchronously get a list of teams from Linear.
+CALLBACK is called with the list of teams on success."
+ (pearl--log "Fetching teams asynchronously")
+ (pearl--progress "Fetching teams...")
+
+ (let* ((query "query { teams { nodes { id name } } }")
+ (success-fn (lambda (response)
+ (if response
+ (let ((teams (cdr (assoc 'nodes (assoc 'teams (assoc 'data response))))))
+ (pearl--log "Retrieved %d teams" (length teams))
+ (setq pearl--cache-teams teams)
+ (when callback
+ (funcall callback teams)))
+ (message "Failed to retrieve teams")
+ (when callback
+ (funcall callback nil)))))
+ (error-fn (lambda (_error _response _data)
+ (message "Failed to retrieve teams")
+ (when callback
+ (funcall callback nil)))))
+
+ (pearl--graphql-request-async query nil success-fn error-fn)))
+
+(defun pearl-get-teams ()
+ "Get a list of teams from Linear (synchronous for backward compatibility)."
+ (let ((teams nil)
+ (completed nil))
+
+ (pearl-get-teams-async
+ (lambda (result)
+ (setq teams result)
+ (setq completed t)))
+
+ (pearl--wait-for (lambda () completed))
+
+ teams))
+
+(defun pearl-select-team-async (callback)
+ "Asynchronously prompt user to select a team.
+CALLBACK is called with the selected team."
+ (if pearl--cache-teams
+ ;; Use cached teams
+ (let* ((team-names (mapcar (lambda (team)
+ (cons (cdr (assoc 'name team)) team))
+ pearl--cache-teams))
+ (selected (completing-read "Select team: " team-names nil t)))
+ (funcall callback (cdr (assoc selected team-names))))
+ ;; Fetch teams first
+ (pearl-get-teams-async
+ (lambda (teams)
+ (if teams
+ (let* ((team-names (mapcar (lambda (team)
+ (cons (cdr (assoc 'name team)) team))
+ teams))
+ (selected (completing-read "Select team: " team-names nil t)))
+ (funcall callback (cdr (assoc selected team-names))))
+ (funcall callback nil))))))
+
+(defun pearl-select-team ()
+ "Prompt user to select a team (synchronous for backward compatibility)."
+ (let ((result nil)
+ (completed nil))
+
+ (pearl-select-team-async
+ (lambda (team)
+ (setq result team)
+ (setq completed t)))
+
+ (pearl--wait-for (lambda () completed))
+
+ result))
+
+;;; Issue Filter DSL (Layer 1)
+
+;; A pure compiler from an authoring plist to a Linear `IssueFilter' object (a
+;; json-encodable alist passed as the `$filter' variable of an `issues(filter:)'
+;; query). Name->id resolution for `:project' / `:cycle' happens upstream; this
+;; layer assumes resolved values and never touches the network.
+
+(defconst pearl--open-state-types '("completed" "canceled" "duplicate")
+ "Workflow-state types that count as not open.
+`:open t' compiles to a `state.type' nin this list. Linear's state types are
+triage, backlog, unstarted, started, completed, canceled, and duplicate; the
+first four are the open ones.")
+
+(defconst pearl--priority-symbols
+ '((none . 0) (urgent . 1) (high . 2) (medium . 3) (low . 4))
+ "Alist mapping priority symbols to Linear's numeric priority values.")
+
+(defconst pearl--filter-keys
+ '(:assignee :open :state :state-type :project :team :labels :priority :cycle
+ :sort :order)
+ "Keys recognized in an issue-filter authoring plist.
+`:sort' and `:order' steer result ordering, not the `IssueFilter' itself.")
+
+(defun pearl--eq (value)
+ "Return a Linear comparator alist matching VALUE exactly."
+ (list (cons "eq" value)))
+
+(defun pearl--in (values)
+ "Return a Linear comparator alist matching any of VALUES.
+VALUES is a list, encoded as a JSON array."
+ (list (cons "in" (vconcat values))))
+
+(defun pearl--nin (values)
+ "Return a Linear comparator alist matching none of VALUES.
+VALUES is a list, encoded as a JSON array."
+ (list (cons "nin" (vconcat values))))
+
+(defun pearl--compile-priority (priority)
+ "Return Linear's numeric value for PRIORITY.
+PRIORITY is an integer 0-4 or a symbol in `pearl--priority-symbols'."
+ (cond ((integerp priority) priority)
+ ((assq priority pearl--priority-symbols)
+ (cdr (assq priority pearl--priority-symbols)))
+ (t (error "Invalid priority: %S" priority))))
+
+(defun pearl--compile-state-filter (plist)
+ "Return the state sub-filter alist for PLIST, or nil when no state key is set.
+An explicit `:state' (name) or `:state-type' (one type or a list) takes
+precedence over `:open', the broad not-closed predicate."
+ (let ((state (plist-get plist :state))
+ (state-type (plist-get plist :state-type))
+ (open (plist-get plist :open)))
+ (cond
+ (state (list (cons "name" (pearl--eq state))))
+ (state-type (list (cons "type"
+ (pearl--in (if (listp state-type)
+ state-type
+ (list state-type))))))
+ (open (list (cons "type" (pearl--nin pearl--open-state-types))))
+ (t nil))))
+
+(defun pearl--build-issue-filter (plist)
+ "Compile filter PLIST into a json-encodable Linear `IssueFilter' alist.
+The result is meant for the `$filter' variable of an `issues(filter:)' query;
+sibling keys are AND-ed by Linear. This function is pure and assumes
+`:project' / `:cycle' values are already resolved ids (see the resolution
+helpers for name->id). Recognized keys are `pearl--filter-keys', minus
+the ordering keys `:sort' / `:order'."
+ (let (filter)
+ (let ((assignee (plist-get plist :assignee)))
+ (cond ((eq assignee :me)
+ (push (cons "assignee" (list (cons "isMe" (pearl--eq t)))) filter))
+ ((stringp assignee)
+ (push (cons "assignee" (list (cons "email" (pearl--eq assignee))))
+ filter))))
+ (let ((state-filter (pearl--compile-state-filter plist)))
+ (when state-filter (push (cons "state" state-filter) filter)))
+ (let ((project (plist-get plist :project)))
+ (when project
+ (push (cons "project" (list (cons "id" (pearl--eq project)))) filter)))
+ (let ((team (plist-get plist :team)))
+ (when team
+ (push (cons "team" (list (cons "key" (pearl--eq team)))) filter)))
+ (let ((labels (plist-get plist :labels)))
+ ;; v1 semantics: "carries any of these labels" -- labels.some.name in set.
+ (when labels
+ (push (cons "labels"
+ (list (cons "some" (list (cons "name" (pearl--in labels))))))
+ filter)))
+ (let ((priority (plist-get plist :priority)))
+ (when priority
+ (push (cons "priority"
+ (pearl--eq (pearl--compile-priority priority)))
+ filter)))
+ (let ((cycle (plist-get plist :cycle)))
+ (when cycle
+ (push (cons "cycle" (list (cons "id" (pearl--eq cycle)))) filter)))
+ (nreverse filter)))
+
+(defun pearl--validate-issue-filter (plist)
+ "Validate issue-filter PLIST, signaling a `user-error' on any problem.
+Return t when PLIST is well-formed. Checks plist shape, unknown keys, the
+`:priority' range/symbol, the `:assignee' form, the `:order' value, empty
+strings, and the `:labels' type. Name ambiguity (a project or state name that
+needs team context) is resolved upstream, not here."
+ (unless (and (listp plist) (cl-evenp (length plist)))
+ (user-error "Issue filter must be a plist"))
+ (cl-loop for (key _val) on plist by #'cddr
+ unless (memq key pearl--filter-keys)
+ do (user-error "Unknown issue-filter key: %S" key))
+ (let ((priority (plist-get plist :priority)))
+ (when priority
+ (cond ((integerp priority)
+ (unless (<= 0 priority 4)
+ (user-error "Priority integer must be 0-4, got %d" priority)))
+ ((not (assq priority pearl--priority-symbols))
+ (user-error "Invalid :priority %S (use 0-4 or none/urgent/high/medium/low)"
+ priority)))))
+ (let ((assignee (plist-get plist :assignee)))
+ (when (and assignee (not (eq assignee :me)) (not (stringp assignee)))
+ (user-error ":assignee must be :me or an email string, got %S" assignee)))
+ (let ((order (plist-get plist :order)))
+ (when (and order (not (memq order '(asc desc))))
+ (user-error ":order must be `asc' or `desc', got %S" order)))
+ (dolist (key '(:state :project :team :cycle))
+ (let ((val (plist-get plist key)))
+ (when (and (stringp val) (string-empty-p val))
+ (user-error "%s must not be an empty string" key))))
+ (let ((labels (plist-get plist :labels)))
+ (when (and labels
+ (or (not (listp labels))
+ (cl-some (lambda (x) (not (and (stringp x) (not (string-empty-p x)))))
+ labels)))
+ (user-error ":labels must be a list of non-empty strings")))
+ t)
+
+;;; Issue Model Normalization
+
+;; Convert raw Linear response alists (json-read shaped: symbol keys, vectors
+;; for arrays, t / `:json-false' booleans, and a missing key for an absent
+;; field) into flat internal plists, so rendering, filtering, and command code
+;; never has to know the transport shape.
+
+(defun pearl--node-list (connection)
+ "Return CONNECTION's nodes as a list.
+CONNECTION is an alist with a `nodes' key holding a vector, a list, or nil."
+ (let ((nodes (cdr (assoc 'nodes connection))))
+ (cond ((vectorp nodes) (append nodes nil))
+ ((listp nodes) nodes)
+ (t nil))))
+
+(defun pearl--normalize-user (raw)
+ "Normalize a Linear user alist RAW to a plist, or nil when RAW is nil."
+ (when raw
+ (list :id (cdr (assoc 'id raw))
+ :name (or (cdr (assoc 'name raw)) (cdr (assoc 'displayName raw)))
+ :email (cdr (assoc 'email raw)))))
+
+(defun pearl--normalize-state (raw)
+ "Normalize a Linear workflow-state alist RAW to a plist, or nil."
+ (when raw
+ (list :id (cdr (assoc 'id raw))
+ :name (cdr (assoc 'name raw))
+ :type (cdr (assoc 'type raw)))))
+
+(defun pearl--normalize-team (raw)
+ "Normalize a Linear team alist RAW to a plist, or nil."
+ (when raw
+ (list :id (cdr (assoc 'id raw))
+ :key (cdr (assoc 'key raw))
+ :name (cdr (assoc 'name raw)))))
+
+(defun pearl--normalize-project (raw)
+ "Normalize a Linear project alist RAW to a plist, or nil."
+ (when raw
+ (list :id (cdr (assoc 'id raw))
+ :name (cdr (assoc 'name raw)))))
+
+(defun pearl--normalize-labels (raw)
+ "Normalize an issue's labels connection RAW to a list of (:id :name) plists."
+ (mapcar (lambda (label)
+ (list :id (cdr (assoc 'id label)) :name (cdr (assoc 'name label))))
+ (pearl--node-list raw)))
+
+(defun pearl--normalize-comment (raw)
+ "Normalize a Linear comment alist RAW to a plist.
+The author falls back through user, then botActor, then externalUser, because
+`Comment.user' is null for integration and bot comments."
+ (let ((user (pearl--normalize-user (cdr (assoc 'user raw))))
+ (bot (cdr (assoc 'botActor raw)))
+ (ext (cdr (assoc 'externalUser raw))))
+ (list :id (cdr (assoc 'id raw))
+ :body (cdr (assoc 'body raw))
+ :created-at (cdr (assoc 'createdAt raw))
+ :author-id (when user (plist-get user :id))
+ :author (cond (user (plist-get user :name))
+ (bot (or (cdr (assoc 'name bot)) "automation"))
+ (ext (or (cdr (assoc 'name ext)) "external"))
+ (t nil)))))
+
+(defun pearl--normalize-cycle (raw)
+ "Normalize a Linear cycle alist RAW to a plist, or nil."
+ (when raw
+ (list :id (cdr (assoc 'id raw))
+ :number (cdr (assoc 'number raw))
+ :name (cdr (assoc 'name raw)))))
+
+(defun pearl--normalize-issue (raw)
+ "Normalize a Linear issue node RAW to a flat internal plist, or nil.
+Vectors become lists and absent/`:json-false' fields become nil. Nested
+objects (state, assignee, team, project, labels, cycle, comments) are
+normalized in turn; comments are omitted unless the fetch requested them."
+ (when raw
+ (list :id (cdr (assoc 'id raw))
+ :identifier (cdr (assoc 'identifier raw))
+ :title (cdr (assoc 'title raw))
+ :description (cdr (assoc 'description raw))
+ :priority (cdr (assoc 'priority raw))
+ :url (cdr (assoc 'url raw))
+ :updated-at (cdr (assoc 'updatedAt raw))
+ :state (pearl--normalize-state (cdr (assoc 'state raw)))
+ :assignee (pearl--normalize-user (cdr (assoc 'assignee raw)))
+ :team (pearl--normalize-team (cdr (assoc 'team raw)))
+ :project (pearl--normalize-project (cdr (assoc 'project raw)))
+ :labels (pearl--normalize-labels (cdr (assoc 'labels raw)))
+ :cycle (pearl--normalize-cycle (cdr (assoc 'cycle raw)))
+ :comments (let ((comments (assoc 'comments raw)))
+ (when comments
+ (mapcar #'pearl--normalize-comment
+ (pearl--node-list (cdr comments))))))))
+
+(defun pearl--normalize-custom-view (raw)
+ "Normalize a Linear custom-view alist RAW to a plist, or nil.
+A workspace-wide view has a nil `:team'; `:shared' is a real boolean."
+ (when raw
+ (list :id (cdr (assoc 'id raw))
+ :name (cdr (assoc 'name raw))
+ :description (cdr (assoc 'description raw))
+ :shared (eq t (cdr (assoc 'shared raw)))
+ :icon (cdr (assoc 'icon raw))
+ :color (cdr (assoc 'color raw))
+ :team (pearl--normalize-team (cdr (assoc 'team raw)))
+ :owner (pearl--normalize-user (cdr (assoc 'owner raw))))))
+
+;;; Query Result Shape
+
+;; A small tagged result so callers can tell apart success-with-issues,
+;; success-with-none, an invalid filter (caught before the request), a
+;; transport failure, and a GraphQL-level failure, instead of collapsing them
+;; all to nil. A result is a plist: (:status SYM :issues LIST :message STR
+;; :truncated BOOL). The fetch layer builds one; commands read it to message
+;; the user precisely.
+
+(defun pearl--make-query-result (status &rest props)
+ "Build a query-result plist with STATUS and optional PROPS.
+STATUS is one of `ok', `empty', `invalid-filter', `request-failed', or
+`graphql-failed'. PROPS may set `:issues', `:message', and `:truncated'."
+ (append (list :status status) props))
+
+(defun pearl--query-result-status (result)
+ "Return the status symbol of query RESULT."
+ (plist-get result :status))
+
+(defun pearl--query-result-issues (result)
+ "Return the issues list carried by query RESULT."
+ (plist-get result :issues))
+
+(defun pearl--query-result-message (result)
+ "Return the user-facing message carried by query RESULT, if any."
+ (plist-get result :message))
+
+(defun pearl--query-result-truncated-p (result)
+ "Return non-nil when query RESULT was cut off at the page cap."
+ (plist-get result :truncated))
+
+(defun pearl--query-result-ok-p (result)
+ "Return non-nil when RESULT is a successful query (issues or empty)."
+ (memq (pearl--query-result-status result) '(ok empty)))
+
+(defun pearl--query-result-error-p (result)
+ "Return non-nil when RESULT is a failure rather than a result set."
+ (memq (pearl--query-result-status result)
+ '(invalid-filter request-failed graphql-failed)))
+
+(defun pearl--graphql-error-message (response)
+ "Return the first GraphQL error message in RESPONSE, or nil.
+RESPONSE's `errors' may be a vector (live API) or a list (test fixtures)."
+ (let ((errors (cdr (assoc 'errors response))))
+ (when errors
+ (let ((first (if (vectorp errors)
+ (and (> (length errors) 0) (aref errors 0))
+ (car errors))))
+ (cdr (assoc 'message first))))))
+
+(defun pearl--classify-response (response &optional issues truncated)
+ "Classify a raw GraphQL RESPONSE into a query-result plist.
+A nil RESPONSE, or one without a `data' key, is a transport failure; a RESPONSE
+carrying `errors' is a GraphQL failure. Otherwise the result is `ok', or
+`empty' when ISSUES is empty, carrying ISSUES and the TRUNCATED flag. ISSUES
+is the already-extracted (normalized) list, so this stays pure."
+ (cond
+ ((null response)
+ (pearl--make-query-result 'request-failed
+ :message "No response from Linear"))
+ ((assoc 'errors response)
+ (pearl--make-query-result
+ 'graphql-failed
+ :message (or (pearl--graphql-error-message response)
+ "Linear returned an error")))
+ ((not (assoc 'data response))
+ (pearl--make-query-result 'request-failed
+ :message "Malformed response from Linear"))
+ (t (pearl--make-query-result (if issues 'ok 'empty)
+ :issues issues
+ :truncated (and truncated t)))))
+
+(defun pearl--invalid-filter-result (message)
+ "Return an `invalid-filter' query-result carrying MESSAGE."
+ (pearl--make-query-result 'invalid-filter :message message))
+
+;;; General Issue Query (Layer 2a)
+
+;; The general fetch: a top-level `issues(filter:)' query paged through a
+;; reusable accumulator, returning a tagged query-result. Issues come back as
+;; raw nodes (the query fetches the full field superset); normalization happens
+;; at the render boundary, not here.
+
+(defconst pearl--issues-query
+ "query Issues($filter: IssueFilter, $first: Int!, $after: String, $orderBy: PaginationOrderBy) {
+ issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) {
+ nodes {
+ id identifier title description priority url updatedAt
+ state { id name type }
+ assignee { id name displayName email }
+ team { id key name }
+ project { id name }
+ labels { nodes { id name } }
+ cycle { id number name }
+ comments { nodes { id body createdAt user { id name displayName } botActor { name } externalUser { name } } }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+}"
+ "GraphQL query for a filtered, ordered page of issues.
+Pulls comments per issue so a populated list renders them, not just the
+single-issue refresh.")
+
+(defconst pearl--single-issue-query
+ "query Issue($id: String!) {
+ issue(id: $id) {
+ id identifier title description priority url updatedAt
+ state { id name type }
+ assignee { id name displayName email }
+ team { id key name }
+ project { id name }
+ labels { nodes { id name } }
+ cycle { id number name }
+ comments { nodes { id body createdAt user { id name displayName } botActor { name } externalUser { name } } }
+ }
+}"
+ "GraphQL query for one issue by id.
+Same field shape as `pearl--issues-query' (which now also pulls comments);
+this is the single-issue refresh path.")
+
+(defun pearl--fetch-issue-async (issue-id callback)
+ "Fetch the full issue node for ISSUE-ID, calling CALLBACK with the outcome.
+CALLBACK receives one of: the raw issue node (normalized at the render
+boundary, as on the list path); `:missing' when the request succeeded but the
+issue is null (deleted, or no access); or `:error' on a GraphQL or transport
+failure. Separating missing from error lets the caller tell \"the issue is
+gone\" apart from \"the API call failed.\""
+ (pearl--graphql-request-async
+ pearl--single-issue-query
+ `(("id" . ,issue-id))
+ (lambda (response)
+ (if (or (null response)
+ (assoc 'errors response)
+ (not (assoc 'data response)))
+ (funcall callback :error)
+ (let ((issue (cdr (assoc 'issue (cdr (assoc 'data response))))))
+ (funcall callback (or issue :missing)))))
+ (lambda (_error _response _data) (funcall callback :error))))
+
+(defconst pearl--view-issues-query
+ "query ViewIssues($id: String!, $first: Int!, $after: String) {
+ customView(id: $id) {
+ issues(first: $first, after: $after) {
+ nodes {
+ id identifier title description priority url updatedAt
+ state { id name type }
+ assignee { id name displayName email }
+ team { id key name }
+ project { id name }
+ labels { nodes { id name } }
+ cycle { id number name }
+ comments { nodes { id body createdAt user { id name displayName } botActor { name } externalUser { name } } }
+ }
+ pageInfo { hasNextPage endCursor }
+ }
+ }
+}"
+ "GraphQL query running a Custom View's own filter server-side, by view id.
+Pulls comments per issue so a view-populated list renders them.")
+
+(defun pearl--query-view-async (view-id callback)
+ "Run the Custom View VIEW-ID server-side, calling CALLBACK with a query-result.
+The view applies its own stored filter on Linear's side; issues come back as
+raw nodes (normalized at the render boundary), paged like the general fetch."
+ (let ((page-fn
+ (lambda (after page-cb)
+ (pearl--graphql-request-async
+ pearl--view-issues-query
+ `(("id" . ,view-id)
+ ("first" . 100)
+ ,@(when after (list (cons "after" after))))
+ (lambda (response)
+ (if (or (null response)
+ (assoc 'errors response)
+ (not (assoc 'data response)))
+ (funcall page-cb
+ (list :error (pearl--classify-response response)))
+ (let* ((conn (cdr (assoc 'issues
+ (cdr (assoc 'customView
+ (cdr (assoc 'data response)))))))
+ (nodes (pearl--node-list conn))
+ (info (cdr (assoc 'pageInfo conn))))
+ (funcall page-cb
+ (list :issues nodes
+ :has-next-page (eq t (cdr (assoc 'hasNextPage info)))
+ :end-cursor (cdr (assoc 'endCursor info)))))))
+ (lambda (_error _response _data)
+ (funcall page-cb
+ (list :error (pearl--make-query-result
+ 'request-failed
+ :message "Failed to fetch view issues"))))))))
+ (pearl--page-issues page-fn callback)))
+
+(defun pearl--custom-views (&optional force)
+ "Return the workspace's Custom Views, fetching once and caching.
+Each node carries id/name/description/shared/url. A non-nil FORCE refetches."
+ (or (and (not force) pearl--cache-views)
+ (let* ((query "query CustomViews($first: Int!, $after: String) {
+ customViews(first: $first, after: $after) {
+ nodes { id name description shared url }
+ pageInfo { hasNextPage endCursor }
+ }
+ }")
+ (response (pearl--graphql-request query '(("first" . 100))))
+ (views (and response
+ (pearl--node-list
+ (cdr (assoc 'customViews (cdr (assoc 'data response))))))))
+ (when views
+ (setq pearl--cache-views views))
+ views)))
+
+(defun pearl--page-issues (page-fn callback &optional max-pages)
+ "Accumulate issues across pages via PAGE-FN, then call CALLBACK with a result.
+PAGE-FN is called as (PAGE-FN AFTER PAGE-CB); it fetches one page and invokes
+PAGE-CB with a plist (:issues LIST :has-next-page BOOL :end-cursor STR) on
+success, or (:error RESULT) carrying a failure query-result. CALLBACK receives
+the final query-result. Paging stops at MAX-PAGES (default
+`pearl-max-issue-pages'), marking the result truncated."
+ (let ((max (or max-pages pearl-max-issue-pages))
+ (acc '())
+ (page 1))
+ (cl-labels
+ ((fetch (after)
+ (if (> page max)
+ (funcall callback
+ (pearl--make-query-result (if acc 'ok 'empty)
+ :issues acc :truncated t))
+ (funcall
+ page-fn after
+ (lambda (page-result)
+ (let ((err (plist-get page-result :error)))
+ (if err
+ (funcall callback err)
+ (setq acc (append acc (plist-get page-result :issues)))
+ (if (plist-get page-result :has-next-page)
+ (progn
+ (setq page (1+ page))
+ (fetch (plist-get page-result :end-cursor)))
+ (funcall callback
+ (pearl--make-query-result
+ (if acc 'ok 'empty) :issues acc))))))))))
+ (fetch nil))))
+
+(defun pearl--query-issues-async (filter callback &optional order-by)
+ "Fetch issues matching FILTER, calling CALLBACK with a query-result.
+FILTER is a compiled `IssueFilter' alist (see
+`pearl--build-issue-filter') or nil for no filter. ORDER-BY is a
+`PaginationOrderBy' symbol, default `updatedAt'. Issues come back as raw
+nodes; normalization happens at the render boundary."
+ (let ((page-fn
+ (lambda (after page-cb)
+ (pearl--graphql-request-async
+ pearl--issues-query
+ `(,@(when filter (list (cons "filter" filter)))
+ ("first" . 100)
+ ,@(when after (list (cons "after" after)))
+ ("orderBy" . ,(symbol-name (or order-by 'updatedAt))))
+ (lambda (response)
+ (if (or (null response)
+ (assoc 'errors response)
+ (not (assoc 'data response)))
+ (funcall page-cb
+ (list :error (pearl--classify-response response)))
+ (let* ((conn (cdr (assoc 'issues (cdr (assoc 'data response)))))
+ (nodes (pearl--node-list conn))
+ (info (cdr (assoc 'pageInfo conn))))
+ (funcall page-cb
+ (list :issues nodes
+ :has-next-page (eq t (cdr (assoc 'hasNextPage info)))
+ :end-cursor (cdr (assoc 'endCursor info)))))))
+ (lambda (_error _response _data)
+ (funcall page-cb
+ (list :error (pearl--make-query-result
+ 'request-failed
+ :message "Failed to fetch issues from Linear"))))))))
+ (pearl--page-issues page-fn callback)))
+
+;;; Issue Creation
+
+(defun pearl--created-issue (response)
+ "Return the created issue node from a create RESPONSE, or nil on failure.
+Linear answers GraphQL-level failures with HTTP 200 and either an `errors'
+body or `issueCreate.success' = false / `issue' = null. Checking `success'
+and the issue node here reports those as failures instead of a phantom
+\"created\" issue."
+ (let* ((issue-create (cdr (assoc 'issueCreate (cdr (assoc 'data response)))))
+ (success (cdr (assoc 'success issue-create)))
+ (issue (cdr (assoc 'issue issue-create))))
+ (and success (not (eq success :json-false)) issue)))
+
+(defun pearl-create-issue-async (title description team-id callback)
+ "Asynchronously create a new issue.
+TITLE is the issue title.
+DESCRIPTION is the issue description.
+TEAM-ID is the team to create the issue in.
+CALLBACK is called with the created issue data."
+ (pearl--log "Creating issue: %s" title)
+ (pearl--progress "Creating issue...")
+
+ (let* ((query "mutation CreateIssue($title: String!, $description: String, $teamId: String!) {
+ issueCreate(input: {title: $title, description: $description, teamId: $teamId}) {
+ success
+ issue {
+ id
+ identifier
+ title
+ }
+ }
+ }")
+ (variables `(("title" . ,title)
+ ("description" . ,description)
+ ("teamId" . ,team-id)))
+
+ (success-fn (lambda (response)
+ (let ((issue (pearl--created-issue response)))
+ (if issue
+ (progn
+ (message "Created issue %s: %s"
+ (cdr (assoc 'identifier issue))
+ (cdr (assoc 'title issue)))
+ (when callback
+ (funcall callback issue)))
+ (message "Failed to create issue")
+ (when callback
+ (funcall callback nil))))))
+
+ (error-fn (lambda (_error _response _data)
+ (message "Failed to create issue")
+ (when callback
+ (funcall callback nil)))))
+
+ (pearl--graphql-request-async query variables success-fn error-fn)))
+
+(defun pearl-create-issue (title description team-id)
+ "Create a new issue (synchronous wrapper for backward compatibility).
+TITLE is the issue title.
+DESCRIPTION is the issue description.
+TEAM-ID is the team to create the issue in."
+ (let ((issue nil)
+ (completed nil))
+
+ (pearl-create-issue-async
+ title description team-id
+ (lambda (result)
+ (setq issue result)
+ (setq completed t)))
+
+ (pearl--wait-for (lambda () completed))
+
+ issue))
+
+;;; Issue State Management (Async)
+
+(defun pearl-get-states-async (team-id callback)
+ "Asynchronously get workflow states for TEAM-ID.
+CALLBACK is called with the list of states."
+ (pearl--log "Fetching workflow states for team %s" team-id)
+
+ (let* ((query "query GetWorkflowStates($teamId: String!) {
+ team(id: $teamId) {
+ states {
+ nodes {
+ id
+ name
+ color
+ }
+ }
+ }
+ }")
+ (variables `(("teamId" . ,team-id)))
+
+ (success-fn (lambda (response)
+ (when response
+ (let ((states (cdr (assoc 'nodes (assoc 'states (assoc 'team (assoc 'data response)))))))
+ (when callback
+ (funcall callback states))))))
+
+ (error-fn (lambda (_error _response _data)
+ (when callback
+ (funcall callback nil)))))
+
+ (pearl--graphql-request-async query variables success-fn error-fn)))
+
+(defun pearl-get-states (team-id)
+ "Get workflow states for TEAM-ID (synchronous wrapper)."
+ (let ((states nil)
+ (completed nil))
+
+ (pearl-get-states-async
+ team-id
+ (lambda (result)
+ (setq states result)
+ (setq completed t)))
+
+ (pearl--wait-for (lambda () completed))
+
+ states))
+
+(defun pearl--team-states (team-id)
+ "Return the workflow states for TEAM-ID, fetching once and caching.
+Cached in `pearl--cache-states' keyed by TEAM-ID; org-state syncs
+resolve a state per heading change, so the cache avoids a network round
+trip on every one. Clear the cache to force a refresh."
+ (or (cdr (assoc team-id pearl--cache-states))
+ (let* ((query "query GetTeamWorkflowStates($teamId: String!) {
+ team(id: $teamId) {
+ states {
+ nodes {
+ id
+ name
+ }
+ }
+ }
+ }")
+ (variables `(("teamId" . ,team-id)))
+ (response (pearl--graphql-request query variables))
+ (states (and response
+ (cdr (assoc 'nodes
+ (cdr (assoc 'states
+ (cdr (assoc 'team
+ (cdr (assoc 'data response)))))))))))
+ (when states
+ (push (cons team-id states) pearl--cache-states))
+ states)))
+
+(defun pearl--get-state-id-by-name (state-name team-id)
+ "Get the Linear state ID for the given STATE-NAME in TEAM-ID."
+ (pearl--log "Looking up state ID for %s in team %s" state-name team-id)
+ (let* ((states (pearl--team-states team-id))
+ (state (and states
+ (seq-find (lambda (s)
+ (string= (downcase (cdr (assoc 'name s)))
+ (downcase state-name)))
+ states))))
+ (if state
+ (cdr (assoc 'id state))
+ (message "Could not find state with name: %s in team %s" state-name team-id)
+ nil)))
+
+(defun pearl--all-teams ()
+ "Return all teams, fetching once and caching in `pearl--cache-teams'.
+The whole-file org sync resolves a team per heading, so caching here turns
+N blocking lookups into one. Shares the cache with the team selector."
+ (or pearl--cache-teams
+ (let* ((query "query {
+ teams {
+ nodes {
+ id
+ name
+ }
+ }
+ }")
+ (response (pearl--graphql-request query))
+ (teams (and response
+ (cdr (assoc 'nodes
+ (cdr (assoc 'teams
+ (cdr (assoc 'data response)))))))))
+ (when teams
+ (setq pearl--cache-teams teams))
+ teams)))
+
+(defun pearl--get-team-id-by-name (team-name)
+ "Get the Linear team ID for the given TEAM-NAME."
+ (pearl--log "Looking up team ID for team %s" team-name)
+ (let* ((teams (pearl--all-teams))
+ (team (and teams
+ (seq-find (lambda (tm)
+ (string= (cdr (assoc 'name tm)) team-name))
+ teams))))
+ (if team
+ (cdr (assoc 'id team))
+ ;; Log the available teams to help diagnose a name mismatch.
+ (when teams
+ (pearl--log "Available teams: %s"
+ (mapconcat (lambda (tm)
+ (format "%s (%s)"
+ (cdr (assoc 'name tm))
+ (cdr (assoc 'id tm))))
+ teams
+ ", ")))
+ (message "Could not find team with name: %s" team-name)
+ nil)))
+
+;;; Per-team Name -> ID Resolution
+
+(defun pearl--team-collection (kind team-id &optional force)
+ "Return the KIND collection for TEAM-ID, fetching once and caching.
+KIND is one of `projects', `labels', `members', `cycles'. Cached in
+`pearl--cache-team-collections' keyed by (KIND . TEAM-ID); a non-nil
+FORCE bypasses the cache and refetches. Returns a list of node alists."
+ (let ((cache-key (cons kind team-id)))
+ (if (and (not force) (assoc cache-key pearl--cache-team-collections))
+ (cdr (assoc cache-key pearl--cache-team-collections))
+ (let* ((fields (pcase kind
+ ('members "id name displayName email")
+ ('cycles "id number name")
+ (_ "id name")))
+ (query (format "query TeamCollection($teamId: String!) {
+ team(id: $teamId) { %s { nodes { %s } } }
+}" (symbol-name kind) fields))
+ (response (pearl--graphql-request query `(("teamId" . ,team-id))))
+ (coll (cdr (assoc kind (cdr (assoc 'team (cdr (assoc 'data response)))))))
+ (nodes (pearl--node-list coll)))
+ (when nodes
+ (push (cons cache-key nodes) pearl--cache-team-collections))
+ nodes))))
+
+(defun pearl--node-label (kind node)
+ "Return a human label for NODE of KIND, shown when disambiguating a match."
+ (pcase kind
+ ('members (or (cdr (assoc 'displayName node))
+ (cdr (assoc 'name node))
+ (cdr (assoc 'email node))))
+ ('cycles (or (cdr (assoc 'name node))
+ (let ((n (cdr (assoc 'number node))) ) (and n (number-to-string n)))))
+ (_ (cdr (assoc 'name node)))))
+
+(defun pearl--node-matches-name-p (kind node name)
+ "Return non-nil when NODE of KIND matches NAME.
+Members match name, displayName, or email; cycles match name or number;
+everything else matches name. Comparison is case-insensitive."
+ (let ((needle (downcase name)))
+ (cl-flet ((eqp (field)
+ (let ((v (cdr (assoc field node))))
+ (and (stringp v) (string= (downcase v) needle)))))
+ (pcase kind
+ ('members (or (eqp 'name) (eqp 'displayName) (eqp 'email)))
+ ('cycles (or (eqp 'name)
+ (let ((n (cdr (assoc 'number node))))
+ (and n (string= (number-to-string n) name)))))
+ (_ (eqp 'name))))))
+
+(defun pearl--resolve-team-id (kind name team-id &optional force)
+ "Resolve NAME to an id within the KIND collection of TEAM-ID, or nil.
+Fetches (and caches) the collection via `pearl--team-collection'. With
+a single match, returns its id; with several, prompts the user to pick; with
+none, returns nil. FORCE refreshes the collection cache first."
+ (let* ((nodes (pearl--team-collection kind team-id force))
+ (matches (seq-filter (lambda (n) (pearl--node-matches-name-p kind n name))
+ nodes)))
+ (pcase (length matches)
+ (0 nil)
+ (1 (cdr (assoc 'id (car matches))))
+ (_ (let* ((choices (mapcar (lambda (n)
+ (cons (format "%s (%s)"
+ (pearl--node-label kind n)
+ (cdr (assoc 'id n)))
+ (cdr (assoc 'id n))))
+ matches))
+ (pick (completing-read (format "Multiple matches for %s: " name)
+ choices nil t)))
+ (cdr (assoc pick choices)))))))
+
+;;;###autoload
+(defun pearl-clear-cache ()
+ "Clear the Linear lookup caches (teams, states, per-team collections, issues).
+Use after renaming things in Linear, or to force the next lookup to refetch."
+ (interactive)
+ (setq pearl--cache-issues nil
+ pearl--cache-teams nil
+ pearl--cache-states nil
+ pearl--cache-team-collections nil
+ pearl--cache-views nil
+ pearl--cache-viewer nil)
+ (message "Linear caches cleared"))
+
+(defun pearl-update-issue-state (issue-id state-name team-id)
+ "Update the state of Linear issue with ISSUE-ID to STATE-NAME for TEAM-ID."
+ (pearl--log "Updating issue %s state to %s for team %s" issue-id state-name team-id)
+ ;; Resolve the state name to an ID first; bail out clearly if the team has
+ ;; no such state rather than firing a mutation with a null stateId.
+ (let ((state-id (pearl--get-state-id-by-name state-name team-id)))
+ (if (null state-id)
+ (message "Cannot update issue %s: no Linear state named %s in team %s"
+ issue-id state-name team-id)
+ (let* ((query "mutation UpdateIssueState($issueId: String!, $stateId: String!) {
+ issueUpdate(id: $issueId, input: {stateId: $stateId}) {
+ success
+ issue {
+ id
+ identifier
+ state {
+ id
+ name
+ }
+ }
+ }
+ }")
+ (variables `(("issueId" . ,issue-id)
+ ("stateId" . ,state-id)))
+ (response (pearl--graphql-request query variables)))
+ (if response
+ (let ((success (and (assoc 'data response)
+ (assoc 'issueUpdate (assoc 'data response))
+ (cdr (assoc 'success (assoc 'issueUpdate (assoc 'data response)))))))
+ (if success
+ (message "Updated issue %s state to %s" issue-id state-name)
+ (pearl--log "Failed to update issue state: %s" (prin1-to-string response))
+ (message "Failed to update issue %s state" issue-id)))
+ (message "Failed to update issue %s state: API error" issue-id))))))
+
+(defun pearl--update-issue-state-async (issue-id state-name team-id)
+ "Asynchronously update a Linear issue's state.
+ISSUE-ID is the Linear issue ID.
+STATE-NAME is the target state name to set.
+TEAM-ID is the team ID of the issue.
+Gives immediate feedback and performs the API update in the background."
+ (pearl--log "Asynchronously updating issue %s state to %s for team %s" issue-id state-name team-id)
+
+ ;; Resolve the state name to an ID first; bail out clearly if the team has
+ ;; no such state rather than firing a mutation with a null stateId.
+ (let ((state-id (pearl--get-state-id-by-name state-name team-id)))
+ (if (null state-id)
+ (message "Cannot update issue %s: no Linear state named %s in team %s"
+ issue-id state-name team-id)
+ (message "Updating issue state to %s... (in background)" state-name)
+ (let* ((query "mutation UpdateIssueState($issueId: String!, $stateId: String!) {
+ issueUpdate(id: $issueId, input: {stateId: $stateId}) {
+ success
+ issue {
+ id
+ identifier
+ state {
+ id
+ name
+ }
+ }
+ }
+ }")
+ (variables `(("issueId" . ,issue-id)
+ ("stateId" . ,state-id)))
+ (success-handler (lambda (data)
+ (let ((success (and (assoc 'data data)
+ (assoc 'issueUpdate (assoc 'data data))
+ (cdr (assoc 'success (assoc 'issueUpdate (assoc 'data data)))))))
+ (if success
+ (message "Successfully updated issue %s state to %s" issue-id state-name)
+ (pearl--log "Failed to update issue state asynchronously: %s" (prin1-to-string data))
+ (message "Failed to update issue %s state in Linear" issue-id)))))
+ (error-handler (lambda (error-thrown _response _data)
+ (message "Error updating issue %s state in Linear: %s" issue-id error-thrown))))
+
+ (pearl--graphql-request-async query variables success-handler error-handler)))))
+
+;;; Team Member and Project Management
+
+(defun pearl-get-team-members (team-id)
+ "Get members for the given TEAM-ID."
+ (pearl--log "Fetching team members for team %s" team-id)
+ (let* ((query "query GetTeamMembers($teamId: String!) {
+ team(id: $teamId) {
+ members {
+ nodes {
+ id
+ name
+ displayName
+ }
+ }
+ }
+ }")
+ (variables `(("teamId" . ,team-id)))
+ (response (pearl--graphql-request query variables)))
+ (when response
+ (let ((members (cdr (assoc 'nodes (assoc 'members (assoc 'team (assoc 'data response)))))))
+ (pearl--log "Retrieved %d team members" (length members))
+ (let ((formatted-members
+ (mapcar (lambda (member)
+ (cons (or (cdr (assoc 'displayName member))
+ (cdr (assoc 'name member)))
+ (cdr (assoc 'id member))))
+ members)))
+ (pearl--log "Formatted team members: %s" (prin1-to-string formatted-members))
+ formatted-members)))))
+
+(defun pearl-get-projects (team-id)
+ "Get a list of projects for the given TEAM-ID."
+ (pearl--log "Fetching projects for team %s" team-id)
+ (let* ((query "query GetProjects($teamId: String!) {
+ team(id: $teamId) {
+ projects {
+ nodes {
+ id
+ name
+ description
+ state
+ }
+ }
+ }
+}")
+ (variables `(("teamId" . ,team-id)))
+ (response (pearl--graphql-request query variables)))
+ (when response
+ (let ((projects (cdr (assoc 'nodes (assoc 'projects (assoc 'team (assoc 'data response)))))))
+ ;; Convert vector to list if needed
+ (when (vectorp projects)
+ (setq projects (append projects nil)))
+ (pearl--log "Retrieved %d projects" (length projects))
+ projects))))
+
+(defun pearl-select-project (team-id)
+ "Prompt user to select a project from TEAM-ID."
+ (let* ((projects (pearl-get-projects team-id))
+ (project-names (when projects
+ (mapcar (lambda (project)
+ (cons (cdr (assoc 'name project)) project))
+ projects)))
+ (selected (when project-names
+ (completing-read "Select project (optional): "
+ (cons "None" project-names) nil t nil nil "None"))))
+ (unless (string= selected "None")
+ (cdr (assoc selected project-names)))))
+
+;;; Other Issue Attributes
+
+(defun pearl-get-priorities ()
+ "Get priority options for Linear issues."
+ ;; Linear uses integers for priorities: 0 (No priority), 1 (Urgent), 2 (High), 3 (Medium), 4 (Low)
+ '(("No priority" . 0)
+ ("Urgent" . 1)
+ ("High" . 2)
+ ("Medium" . 3)
+ ("Low" . 4)))
+
+(defun pearl-get-issue-types (team-id)
+ "Get issue types for the given TEAM-ID."
+ (pearl--log "Fetching issue types for team %s" team-id)
+ (let* ((query "query GetIssueTypes($teamId: String!) {
+ team(id: $teamId) {
+ labels {
+ nodes {
+ id
+ name
+ color
+ }
+ }
+ }
+ }")
+ (variables `(("teamId" . ,team-id)))
+ (response (pearl--graphql-request query variables)))
+ (when response
+ (let ((labels (cdr (assoc 'nodes (assoc 'labels (assoc 'team (assoc 'data response)))))))
+ (mapcar (lambda (label)
+ (cons (cdr (assoc 'name label))
+ (cdr (assoc 'id label))))
+ labels)))))
+
+;;; Org Mode Integration
+
+(defun pearl-org-hook-function ()
+ "Sync to Linear when the configured Linear org file is saved.
+Fires only for the buffer visiting `pearl-org-file-path', so a
+custom output path is honored instead of a hardcoded \"linear.org\" name."
+ (when (and buffer-file-name
+ pearl-org-file-path
+ (string-equal (file-truename buffer-file-name)
+ (file-truename pearl-org-file-path)))
+ (pearl--log "Linear org file saved, syncing changes to Linear API")
+ (pearl-sync-org-to-linear)))
+
+(defun pearl--extract-org-heading-properties ()
+ "Extract Linear issue properties from the org entry at point.
+Returns a plist with :todo-state, :issue-id, :issue-identifier, and :team-id,
+read from the entry's LINEAR-* property drawer via org APIs. Works from
+anywhere inside the entry and at any heading depth, and reads by property name
+rather than scanning lines, so body text or nested sub-entries don't confuse
+it. Returns nil when point is not within a heading; the id fields are nil for
+a non-issue heading. The team id is read directly from `LINEAR-TEAM-ID', so
+there is no network lookup here."
+ (save-excursion
+ (when (ignore-errors (org-back-to-heading t) t)
+ (list :todo-state (org-get-todo-state)
+ :issue-id (org-entry-get nil "LINEAR-ID")
+ :issue-identifier (org-entry-get nil "LINEAR-IDENTIFIER")
+ :team-id (org-entry-get nil "LINEAR-TEAM-ID")))))
+
+
+(defun pearl--process-heading-at-point ()
+ "Process the Linear issue at the current org heading."
+ (let* ((props (pearl--extract-org-heading-properties))
+ (todo-state (plist-get props :todo-state))
+ (issue-id (plist-get props :issue-id))
+ (issue-identifier (plist-get props :issue-identifier))
+ (team-id (plist-get props :team-id)))
+
+ ;; Only sync when this heading is a Linear issue (has id, identifier, team).
+ (when (and issue-id issue-identifier team-id)
+ (let ((linear-state (pearl--map-org-state-to-linear todo-state)))
+ (when linear-state
+ (pearl--update-issue-state-async issue-id linear-state team-id))))))
+
+(defun pearl-sync-org-to-linear ()
+ "Syncs change from linear.org to Linear API."
+ (interactive)
+ ;; If called from org-after-todo-state-change-hook, just process the current heading
+ (if (eq this-command 'org-todo)
+ (pearl-sync-current-heading-to-linear)
+ ;; Otherwise, scan the entire file
+ (save-excursion
+ (goto-char (point-min))
+ (let ((todo-states-pattern (pearl--get-todo-states-pattern)))
+ (while (re-search-forward (format "^\\*+ \\(%s\\)" todo-states-pattern) nil t)
+ ;; Process at the heading start, but keep the outer point at the end
+ ;; of this match so the next search advances past it. Without the
+ ;; save-excursion, `beginning-of-line' rewinds point and the search
+ ;; re-matches the same heading forever.
+ (save-excursion
+ (beginning-of-line)
+ (pearl--process-heading-at-point)))))))
+
+(defun pearl-sync-current-heading-to-linear ()
+ "Sync the current org heading's TODO state to the Linear API.
+Used when directly changing a TODO state in the org buffer."
+ (save-excursion
+ ;; Move up to the enclosing heading. `org-back-to-heading' signals
+ ;; "before first heading" in the preamble; guard so the sync entry point
+ ;; degrades to a no-op there instead of erroring.
+ (when (ignore-errors (org-back-to-heading t) t)
+ (pearl--process-heading-at-point))))
+
+;;; Mapping Functions
+
+(defun pearl--map-linear-state-to-org (state)
+ "Map Linear STATE name to an Org TODO keyword string.
+STATE is the Linear state string."
+ (or (cdr (assoc state pearl-state-to-todo-mapping))
+ "TODO")) ; Default fallback
+
+(defun pearl--map-org-state-to-linear (todo-state)
+ "Map an Org TODO-STATE keyword to a Linear state name.
+TODO-STATE is the Org keyword string."
+ (or (car (rassoc todo-state pearl-state-to-todo-mapping))
+ nil))
+
+
+(defun pearl--get-todo-states-pattern ()
+ "Return the regex pattern matching the Org TODO states.
+Built from the org keywords in `pearl-state-to-todo-mapping' and
+cached in `pearl-todo-states-pattern'. The cache is rebuilt when
+the mapping changes, so a mid-session `setq' or customization takes effect."
+ (unless (and pearl-todo-states-pattern
+ (eq pearl--todo-states-pattern-source
+ pearl-state-to-todo-mapping))
+ ;; Pattern like "TODO\\|IN-PROGRESS\\|IN-REVIEW\\|BACKLOG\\|BLOCKED\\|DONE".
+ (setq pearl--todo-states-pattern-source pearl-state-to-todo-mapping
+ pearl-todo-states-pattern
+ (mapconcat #'regexp-quote
+ (mapcar #'cdr pearl-state-to-todo-mapping)
+ "\\|")))
+ pearl-todo-states-pattern)
+
+(defun pearl--map-linear-priority-to-org (priority-num)
+ "Convert Linear PRIORITY-NUM to an Org priority cookie.
+PRIORITY-NUM is 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low."
+ (cond
+ ((eq priority-num 1) "[#A]") ; Urgent -> A
+ ((eq priority-num 2) "[#B]") ; High -> B
+ ((eq priority-num 3) "[#C]") ; Medium -> C
+ ((eq priority-num 4) "[#D]") ; Low -> D
+ (t "[#C]"))) ; Default -> C
+
+(defun pearl--get-linear-priority-name (priority-num)
+ "Convert Linear PRIORITY-NUM to a readable name.
+PRIORITY-NUM is 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low."
+ (cond
+ ((eq priority-num 1) "Urgent")
+ ((eq priority-num 2) "High")
+ ((eq priority-num 3) "Medium")
+ ((eq priority-num 4) "Low")
+ (t "Medium")))
+
+(defun pearl--md-line-to-org (line)
+ "Convert inline markdown in LINE to Org markup.
+Handles links, inline code, bold, and underscore italics; other inline markup
+passes through unchanged."
+ (let ((s line))
+ ;; [text](url) -> [[url][text]] (before code/emphasis touch the brackets)
+ (setq s (replace-regexp-in-string
+ "\\[\\([^]]+\\)\\](\\([^) ]+\\))" "[[\\2][\\1]]" s))
+ ;; `code` -> ~code~
+ (setq s (replace-regexp-in-string "`\\([^`\n]+\\)`" "~\\1~" s))
+ ;; **bold** -> *bold*
+ (setq s (replace-regexp-in-string "\\*\\*\\([^*\n]+?\\)\\*\\*" "*\\1*" s))
+ ;; _italic_ -> /italic/, word-bounded so identifiers like foo_bar are left alone
+ (setq s (replace-regexp-in-string
+ "\\(^\\|[^[:alnum:]_]\\)_\\([^_\n]+?\\)_\\([^[:alnum:]_]\\|$\\)"
+ "\\1/\\2/\\3" s))
+ s))
+
+(defun pearl--md-to-org (md)
+ "Convert markdown MD to Org markup (the pure-elisp conversion tier).
+Converts fenced code blocks to `#+begin_src'/`#+end_src', markdown headings to
+bold lines (never Org headings), markdown bullets (`*' / `+') to `-', and the
+inline markup in `pearl--md-line-to-org'. Any other line that would
+read as an Org heading is space-guarded. Tables, HTML, and unrecognized
+constructs pass through as literal text. Returns the empty string for an empty
+MD."
+ (if (or (null md) (string-empty-p md))
+ ""
+ (let ((in-code nil) (out '()))
+ (dolist (line (split-string md "\n"))
+ (cond
+ ;; fenced code fence: ``` or ```lang
+ ((string-match "\\`[ \t]*```\\(.*\\)\\'" line)
+ (if in-code
+ (progn (push "#+end_src" out) (setq in-code nil))
+ (push (format "#+begin_src %s" (string-trim (match-string 1 line))) out)
+ (setq in-code t)))
+ (in-code (push line out))
+ ;; markdown heading -> bold line, not an Org heading
+ ((string-match "\\`#+[ \t]+\\(.*\\)\\'" line)
+ (push (format "*%s*" (string-trim (match-string 1 line))) out))
+ ;; markdown bullet -> Org dash bullet
+ ((string-match "\\`\\([ \t]*\\)[*+][ \t]+\\(.*\\)\\'" line)
+ (push (concat (match-string 1 line) "- "
+ (pearl--md-line-to-org (match-string 2 line)))
+ out))
+ ;; guard any remaining line that Org would read as a heading
+ ((string-match "\\`\\*+ " line)
+ (push (concat " " (pearl--md-line-to-org line)) out))
+ (t (push (pearl--md-line-to-org line) out))))
+ (string-join (nreverse out) "\n"))))
+
+(defun pearl--org-line-to-md (line)
+ "Convert inline Org markup in LINE back to markdown.
+The inverse of `pearl--md-line-to-org': org links, verbatim, bold, and
+italics become their markdown forms. Other text passes through unchanged.
+Italics are word-bounded so filesystem paths and URLs are left alone."
+ (let ((s line))
+ ;; [[url][text]] -> [text](url) (before verbatim/emphasis touch brackets)
+ (setq s (replace-regexp-in-string
+ "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" "[\\2](\\1)" s))
+ ;; [[url]] -> url
+ (setq s (replace-regexp-in-string "\\[\\[\\([^]]+\\)\\]\\]" "\\1" s))
+ ;; ~code~ -> `code`
+ (setq s (replace-regexp-in-string "~\\([^~\n]+\\)~" "`\\1`" s))
+ ;; *bold* -> **bold**
+ (setq s (replace-regexp-in-string "\\*\\([^*\n]+?\\)\\*" "**\\1**" s))
+ ;; /italic/ -> _italic_, word-bounded so /usr/local paths are left alone
+ (setq s (replace-regexp-in-string
+ "\\(^\\|[^[:alnum:]/]\\)/\\([^/\n]+?\\)/\\([^[:alnum:]/]\\|$\\)"
+ "\\1_\\2_\\3" s))
+ s))
+
+(defun pearl--org-to-md (org)
+ "Convert Org markup ORG back to markdown (the push direction).
+The symmetric inverse of `pearl--md-to-org': `#+begin_src'/`#+end_src'
+become fenced code (language preserved, contents verbatim), `#+begin_quote'
+blocks become `>'-prefixed lines, Org checkbox marks normalize to markdown
+lowercase, and the inline markup in `pearl--org-line-to-md' is undone.
+Tables, HTML, and unrecognized lines pass through literally. Returns the
+empty string for an empty ORG.
+
+Two constructs are intentionally lossy and do not round-trip back to their
+markdown source: a markdown `# heading' (rendered to a bold line on fetch)
+stays a bold line, and single-asterisk markdown italics are unsupported on
+fetch. Both are documented in the conversion matrix."
+ (if (or (null org) (string-empty-p org))
+ ""
+ (let ((in-code nil) (in-quote nil) (out '()))
+ (dolist (line (split-string org "\n"))
+ (cond
+ ;; src block open: #+begin_src or #+begin_src lang
+ ((string-match "\\`[ \t]*#\\+begin_src\\(.*\\)\\'" line)
+ (push (format "```%s" (string-trim (match-string 1 line))) out)
+ (setq in-code t))
+ ((and in-code (string-match "\\`[ \t]*#\\+end_src[ \t]*\\'" line))
+ (push "```" out) (setq in-code nil))
+ (in-code (push line out))
+ ;; quote block: drop the markers, prefix the contents with "> "
+ ((string-match "\\`[ \t]*#\\+begin_quote[ \t]*\\'" line)
+ (setq in-quote t))
+ ((and in-quote (string-match "\\`[ \t]*#\\+end_quote[ \t]*\\'" line))
+ (setq in-quote nil))
+ (in-quote (push (concat "> " (pearl--org-line-to-md line)) out))
+ ;; checkbox: normalize the uppercase Org mark to markdown lowercase
+ ((string-match "\\`\\([ \t]*\\)- \\[\\([ xX]\\)\\] \\(.*\\)\\'" line)
+ (push (concat (match-string 1 line)
+ "- [" (downcase (match-string 2 line)) "] "
+ (pearl--org-line-to-md (match-string 3 line)))
+ out))
+ (t (push (pearl--org-line-to-md line) out))))
+ (string-join (nreverse out) "\n"))))
+
+(defun pearl--format-comment (comment)
+ "Format a normalized COMMENT plist as a level-4 Org entry.
+The heading carries the author and timestamp; a property drawer carries the
+comment id, the author's user id (empty for bot/external comments, which are
+not editable), and a sha256 of the last-fetched body for the sync conflict
+gate. The body runs through the same markdown->org tier as the description.
+A null author renders as `(unknown)'."
+ (let ((author (or (plist-get comment :author) "(unknown)"))
+ (created (or (plist-get comment :created-at) ""))
+ (raw-body (or (plist-get comment :body) ""))
+ (body (pearl--md-to-org (or (plist-get comment :body) ""))))
+ (concat (format "**** %s — %s\n" author created)
+ ":PROPERTIES:\n"
+ (format ":LINEAR-COMMENT-ID: %s\n" (or (plist-get comment :id) ""))
+ (format ":LINEAR-COMMENT-AUTHOR-ID: %s\n" (or (plist-get comment :author-id) ""))
+ (format ":LINEAR-COMMENT-SHA256: %s\n" (secure-hash 'sha256 raw-body))
+ ":END:\n"
+ (if (string-empty-p body) "" (concat body "\n")))))
+
+(defun pearl--format-comments (comments)
+ "Format COMMENTS (a list of normalized comment plists) as a Comments subtree.
+Comments render oldest-first under a level-3 `Comments' heading. Returns the
+empty string when COMMENTS is nil, so an issue with no comments renders no
+subtree."
+ (if (null comments)
+ ""
+ (let ((sorted (sort (copy-sequence comments)
+ (lambda (a b)
+ (string< (or (plist-get a :created-at) "")
+ (or (plist-get b :created-at) ""))))))
+ (concat "*** Comments\n"
+ (mapconcat #'pearl--format-comment sorted "")))))
+
+(defun pearl--format-issue-as-org-entry (issue)
+ "Format a normalized ISSUE plist as an Org entry.
+The heading carries the title; structured fields live in a namespaced
+`LINEAR-*' property drawer (changed via commands, not by hand); the issue
+description renders as the entry body. `LINEAR-DESC-SHA256' (the markdown) and
+`LINEAR-DESC-UPDATED-AT' record the description's provenance for the sync-back
+conflict gates; `LINEAR-DESC-ORG-SHA256' hashes the rendered Org body so a
+later refresh can tell a real local edit from a lossy md->org round-trip
+without re-deriving the markdown. `LINEAR-TITLE-SHA256' is the title's hash
+(over the rendered, bracket-stripped title)."
+ (let* ((title (or (plist-get issue :title) ""))
+ (description (or (plist-get issue :description) ""))
+ (state (plist-get issue :state))
+ (team (plist-get issue :team))
+ (project (plist-get issue :project))
+ (assignee (plist-get issue :assignee))
+ (todo (pearl--map-linear-state-to-org (plist-get state :name)))
+ (priority (pearl--map-linear-priority-to-org (plist-get issue :priority)))
+ (sanitized-title (replace-regexp-in-string "\\[\\|\\]" "" title))
+ (label-names (mapconcat (lambda (l) (or (plist-get l :name) ""))
+ (plist-get issue :labels) ", "))
+ (body-org (pearl--md-to-org description)))
+ (concat
+ (format "** %s %s %s\n" todo priority sanitized-title)
+ ":PROPERTIES:\n"
+ (format ":LINEAR-ID: %s\n" (or (plist-get issue :id) ""))
+ (format ":LINEAR-IDENTIFIER: %s\n" (or (plist-get issue :identifier) ""))
+ (format ":LINEAR-URL: %s\n"
+ (or (plist-get issue :url)
+ (format "https://linear.app/issue/%s" (or (plist-get issue :identifier) ""))))
+ (format ":LINEAR-TEAM-ID: %s\n" (or (plist-get team :id) ""))
+ (format ":LINEAR-TEAM-NAME: %s\n" (or (plist-get team :name) ""))
+ (format ":LINEAR-PROJECT-ID: %s\n" (or (plist-get project :id) ""))
+ (format ":LINEAR-PROJECT-NAME: %s\n" (or (plist-get project :name) ""))
+ (format ":LINEAR-STATE-ID: %s\n" (or (plist-get state :id) ""))
+ (format ":LINEAR-STATE-NAME: %s\n" (or (plist-get state :name) ""))
+ (format ":LINEAR-ASSIGNEE-ID: %s\n" (or (plist-get assignee :id) ""))
+ (format ":LINEAR-ASSIGNEE-NAME: %s\n" (or (plist-get assignee :name) ""))
+ (format ":LINEAR-LABELS: [%s]\n" label-names)
+ (format ":LINEAR-DESC-SHA256: %s\n" (secure-hash 'sha256 description))
+ (format ":LINEAR-DESC-ORG-SHA256: %s\n" (secure-hash 'sha256 (string-trim body-org)))
+ (format ":LINEAR-DESC-UPDATED-AT: %s\n" (or (plist-get issue :updated-at) ""))
+ (format ":LINEAR-TITLE-SHA256: %s\n" (secure-hash 'sha256 sanitized-title))
+ ":END:\n"
+ (if (string-empty-p body-org) "" (concat body-org "\n"))
+ (pearl--format-comments (plist-get issue :comments)))))
+
+;;; Description Sync-Back
+
+(defun pearl--sync-decision (local-md stored-hash remote-md)
+ "Decide how to sync a description edit.
+LOCAL-MD is the current Org body rendered back to markdown. STORED-HASH is
+the sha256 of the markdown recorded when the issue was last fetched. REMOTE-MD
+is the description as it stands on Linear right now. Returns:
+
+- `:noop' -- nothing to push (no local edit, or local already matches
+ remote),
+- `:push' -- a local edit against an unchanged remote: safe to push,
+- `:conflict' -- both sides changed since the last fetch."
+ (let ((local-hash (secure-hash 'sha256 (or local-md "")))
+ (remote-hash (secure-hash 'sha256 (or remote-md ""))))
+ (cond
+ ((string= local-hash stored-hash) :noop)
+ ((string= local-hash remote-hash) :noop)
+ ((string= remote-hash stored-hash) :push)
+ (t :conflict))))
+
+;;; Conflict Resolution
+
+;; When a description, title, or comment changed both locally and on Linear
+;; since the last fetch, `pearl--sync-decision' returns `:conflict'. Instead of
+;; only refusing, the sync commands offer use-local / use-remote / rewrite (and
+;; cancel). The hard rule: no resolution silently discards the local edit, so
+;; any destructive choice stashes it first (see `pearl--stash-conflict-text').
+
+(defconst pearl--conflict-backup-buffer "*pearl-conflict-backup*"
+ "Buffer holding stashed local text from destructive conflict resolutions.")
+
+(defun pearl--stash-conflict-text (label text)
+ "Stash TEXT so a destructive conflict resolution can't lose it.
+Pushes TEXT onto the `kill-ring' (recover with `yank') and appends it to the
+`pearl--conflict-backup-buffer' under a heading naming LABEL and the time.
+Called before \"use remote\" or a rewrite replaces the local edit. Empty TEXT
+is a no-op -- there is nothing to lose."
+ (when (and text (not (string-empty-p text)))
+ (kill-new text)
+ (with-current-buffer (get-buffer-create pearl--conflict-backup-buffer)
+ (goto-char (point-max))
+ (unless (bobp) (insert "\n"))
+ (insert (format "## %s -- %s\n" label
+ (format-time-string "%Y-%m-%d %H:%M:%S")))
+ (insert text)
+ (unless (string-suffix-p "\n" text) (insert "\n")))))
+
+(defun pearl--conflict-smerge-string (local remote)
+ "Return a git-style merge-conflict string with LOCAL and REMOTE sections.
+LOCAL is the user's current text, REMOTE is Linear's. Each section is newline
+terminated so the markers always start their own line; the result is meant to
+drop into `smerge-mode' for resolution."
+ (concat "<<<<<<< LOCAL (your edit)\n"
+ local (unless (string-suffix-p "\n" local) "\n")
+ "=======\n"
+ remote (unless (string-suffix-p "\n" remote) "\n")
+ ">>>>>>> REMOTE (Linear)\n"))
+
+(defun pearl--read-conflict-resolution (label)
+ "Prompt for how to resolve the conflict on LABEL, returning a symbol.
+One of `use-local' (push mine, overwrite Linear), `use-remote' (take Linear's,
+stash mine), `rewrite' (merge both in a buffer), or `cancel'. A bare RET
+defaults to `cancel', leaving everything untouched."
+ (let* ((choices '(("cancel -- leave both untouched" . cancel)
+ ("use local -- push my version, overwrite Linear" . use-local)
+ ("use remote -- take Linear's, stash mine" . use-remote)
+ ("rewrite -- merge both in a buffer" . rewrite)))
+ (default (caar choices))
+ (pick (completing-read
+ (format "Conflict on %s (RET cancels): " label)
+ (mapcar #'car choices) nil t nil nil default)))
+ (cdr (assoc pick choices))))
+
+(defun pearl--set-entry-body-at-point (org-text)
+ "Replace the body of the org entry at point with ORG-TEXT.
+The body is the region after the entry's drawers and before its first child
+heading, so a Comments subtree is left intact. ORG-TEXT is org markup (already
+converted from markdown); an empty string clears the body. Used to write a
+resolved description or comment back into the buffer."
+ (save-excursion
+ (org-back-to-heading t)
+ (org-end-of-meta-data t)
+ (let ((beg (point))
+ (end (save-excursion (outline-next-heading) (point))))
+ (delete-region beg end)
+ (goto-char beg)
+ (unless (string-empty-p org-text)
+ (insert org-text "\n")))))
+
+(defun pearl--resolve-conflict (label local-md remote-md marker stored-prop apply-fn push-fn)
+ "Interactively resolve a sync conflict on LABEL.
+LOCAL-MD and REMOTE-MD are the two diverged versions. MARKER anchors the org
+entry; STORED-PROP is the provenance property advanced on resolution (such as
+\"LINEAR-DESC-SHA256\"). APPLY-FN takes a text string and writes it into the
+buffer (re-rendering the body, title, or comment); PUSH-FN takes a text string
+and a callback invoked with non-nil on a successful push.
+
+Resolutions (see `pearl--read-conflict-resolution'): `cancel' leaves both
+untouched; `use-local' pushes the local text and advances the hash on success;
+`use-remote' stashes the local text, writes Linear's version in, and advances
+the hash with no push (the remote is already current). `rewrite' is the
+deferred smerge flow -- for now it stashes the local text and redirects, so
+nothing is lost."
+ (pcase (pearl--read-conflict-resolution label)
+ ('cancel
+ (message "Left %s untouched (refresh to see Linear's version)" label))
+ ('use-local
+ (funcall push-fn local-md
+ (lambda (ok)
+ (if ok
+ (progn
+ (org-entry-put marker stored-prop
+ (secure-hash 'sha256 local-md))
+ (message "Pushed your %s to Linear" label))
+ (message "Failed to push %s" label)))))
+ ('use-remote
+ (pearl--stash-conflict-text label local-md)
+ (funcall apply-fn remote-md)
+ (org-entry-put marker stored-prop (secure-hash 'sha256 remote-md))
+ (message "Took Linear's %s; your version is on the kill ring and in %s"
+ label pearl--conflict-backup-buffer))
+ ('rewrite
+ (pearl--stash-conflict-text label local-md)
+ (pearl--resolve-conflict-in-smerge
+ label local-md remote-md
+ (lambda (reconciled)
+ (funcall apply-fn reconciled)
+ (funcall push-fn reconciled
+ (lambda (ok)
+ (if ok
+ (progn
+ (org-entry-put marker stored-prop
+ (secure-hash 'sha256 reconciled))
+ (message "Synced merged %s to Linear" label))
+ (message "Failed to push merged %s" label)))))))))
+
+(defvar-local pearl--conflict-on-finish nil
+ "Callback invoked with the reconciled text when a conflict buffer commits.")
+
+(defun pearl--conflict-has-markers-p (text)
+ "Return non-nil if any git-style conflict markers remain in TEXT.
+Used to refuse a commit while the user has left a section unresolved."
+ (and (string-match-p "^\\(<<<<<<<\\|=======\\|>>>>>>>\\)" text) t))
+
+(defun pearl--resolve-conflict-in-smerge (label local remote on-finish)
+ "Open an smerge buffer to reconcile LOCAL vs REMOTE for LABEL.
+The user resolves the conflict markers with the usual `smerge-mode' commands,
+then \\[pearl--conflict-commit] hands the reconciled text to ON-FINISH and kills
+the buffer, while \\[pearl--conflict-abort] cancels (the local text is already
+stashed). ON-FINISH runs only when no conflict markers remain."
+ (require 'smerge-mode)
+ (let ((buf (get-buffer-create (format "*pearl-merge: %s*" label))))
+ (with-current-buffer buf
+ (erase-buffer)
+ (insert (pearl--conflict-smerge-string local remote))
+ (goto-char (point-min))
+ (smerge-mode 1)
+ (setq-local pearl--conflict-on-finish on-finish)
+ (local-set-key (kbd "C-c C-c") #'pearl--conflict-commit)
+ (local-set-key (kbd "C-c C-k") #'pearl--conflict-abort)
+ (setq-local header-line-format
+ (substitute-command-keys
+ "Resolve the conflict, then \\[pearl--conflict-commit] to push, \\[pearl--conflict-abort] to abort")))
+ (pop-to-buffer buf)))
+
+(defun pearl--conflict-commit ()
+ "Finish the current pearl conflict buffer, pushing the reconciled text.
+Refuses while conflict markers remain; otherwise hands the buffer text to the
+armed ON-FINISH callback and kills the buffer."
+ (interactive)
+ (let ((text (buffer-string)))
+ (when (pearl--conflict-has-markers-p text)
+ (user-error "Unresolved conflict markers remain; resolve them or abort with C-c C-k"))
+ (let ((cb pearl--conflict-on-finish))
+ (kill-buffer (current-buffer))
+ (when cb (funcall cb text)))))
+
+(defun pearl--conflict-abort ()
+ "Abort the current pearl conflict buffer without pushing.
+The local text was stashed before the buffer opened, so nothing is lost."
+ (interactive)
+ (kill-buffer (current-buffer))
+ (message "Conflict resolution aborted; your text is on the kill ring and in %s"
+ pearl--conflict-backup-buffer))
+
+(defun pearl--issue-body-at-point ()
+ "Return the description body of the Linear issue subtree at point.
+The body is the text after the property drawer and before the first child
+heading (so a Comments subtree is excluded), trimmed of surrounding
+whitespace. Returns the empty string when the entry has no body."
+ (save-excursion
+ (org-back-to-heading t)
+ ;; Fix the body's end (the next heading: a Comments child or the next
+ ;; issue) from the heading itself, before `org-end-of-meta-data' moves
+ ;; point. For an empty body that call skips blank lines and lands on the
+ ;; next heading, so without this clamp the body would overshoot into the
+ ;; next issue's whole subtree.
+ (let ((end (save-excursion (outline-next-heading) (point))))
+ (org-end-of-meta-data t)
+ (if (>= (point) end)
+ ""
+ (string-trim (buffer-substring-no-properties (point) end))))))
+
+(defun pearl--fetch-issue-description-async (issue-id callback)
+ "Fetch ISSUE-ID's current description and timestamp from Linear.
+CALLBACK is called with a plist (:description STR :updated-at STR) on success,
+or nil on error."
+ (let ((query "query IssueDescription($id: String!) {
+ issue(id: $id) {
+ description
+ updatedAt
+ }
+ }")
+ (variables `(("id" . ,issue-id))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let ((issue (cdr (assoc 'issue (assoc 'data data)))))
+ (funcall callback
+ (when issue
+ (list :description (or (cdr (assoc 'description issue)) "")
+ :updated-at (cdr (assoc 'updatedAt issue)))))))
+ (lambda (_error _response _data) (funcall callback nil)))))
+
+(defun pearl--update-issue-description-async (issue-id markdown callback)
+ "Push MARKDOWN as ISSUE-ID's description via issueUpdate.
+CALLBACK is called with a plist (:success BOOL :updated-at STR)."
+ (let ((query "mutation UpdateIssueDescription($id: String!, $description: String!) {
+ issueUpdate(id: $id, input: {description: $description}) {
+ success
+ issue {
+ id
+ updatedAt
+ }
+ }
+ }")
+ (variables `(("id" . ,issue-id) ("description" . ,markdown))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let* ((payload (cdr (assoc 'issueUpdate (assoc 'data data))))
+ (success (eq t (cdr (assoc 'success payload))))
+ (issue (cdr (assoc 'issue payload))))
+ (funcall callback
+ (list :success success
+ :updated-at (cdr (assoc 'updatedAt issue))))))
+ (lambda (_error _response _data) (funcall callback '(:success nil))))))
+
+(defun pearl--fetch-issue-title-async (issue-id callback)
+ "Fetch ISSUE-ID's current title and timestamp from Linear.
+CALLBACK is called with a plist (:title STR :updated-at STR) on success, or nil
+on error."
+ (let ((query "query IssueTitle($id: String!) {
+ issue(id: $id) {
+ title
+ updatedAt
+ }
+ }")
+ (variables `(("id" . ,issue-id))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let ((issue (cdr (assoc 'issue (assoc 'data data)))))
+ (funcall callback
+ (when issue
+ (list :title (or (cdr (assoc 'title issue)) "")
+ :updated-at (cdr (assoc 'updatedAt issue)))))))
+ (lambda (_error _response _data) (funcall callback nil)))))
+
+(defun pearl--update-issue-title-async (issue-id title callback)
+ "Push TITLE as ISSUE-ID's title via issueUpdate.
+CALLBACK is called with a plist (:success BOOL :updated-at STR)."
+ (let ((query "mutation UpdateIssueTitle($id: String!, $title: String!) {
+ issueUpdate(id: $id, input: {title: $title}) {
+ success
+ issue {
+ id
+ updatedAt
+ }
+ }
+ }")
+ (variables `(("id" . ,issue-id) ("title" . ,title))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let* ((payload (cdr (assoc 'issueUpdate (assoc 'data data))))
+ (success (eq t (cdr (assoc 'success payload))))
+ (issue (cdr (assoc 'issue payload))))
+ (funcall callback
+ (list :success success
+ :updated-at (cdr (assoc 'updatedAt issue))))))
+ (lambda (_error _response _data) (funcall callback '(:success nil))))))
+
+(defun pearl--issue-title-at-point ()
+ "Return the title of the Linear issue heading at point.
+Strips the TODO keyword, priority cookie, and tags, leaving the bare title
+text (which is the bracket-stripped form the renderer wrote)."
+ (save-excursion
+ (org-back-to-heading t)
+ (org-get-heading t t t t)))
+
+;;;###autoload
+(defun pearl-sync-current-issue ()
+ "Push the description edited in the Org body of the issue at point to Linear.
+Works from anywhere inside an issue subtree. The push is gated: if the body
+is unchanged since the last fetch nothing is sent; if it was edited and the
+remote is unchanged the edit is pushed and the provenance properties advance;
+if both the body and the remote changed since the last fetch the push is
+refused and the conflict reported (refresh to reconcile)."
+ (interactive)
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (stored (org-entry-get nil "LINEAR-DESC-SHA256"))
+ (marker (point-marker)))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (let* ((local-md (pearl--org-to-md (pearl--issue-body-at-point))))
+ (if (string= (secure-hash 'sha256 local-md) (or stored ""))
+ (message "No description changes to sync for %s" issue-id)
+ (pearl--progress "Checking Linear for remote changes...")
+ (pearl--fetch-issue-description-async
+ issue-id
+ (lambda (remote)
+ (if (null remote)
+ (message "Could not fetch %s from Linear; not syncing" issue-id)
+ (pcase (pearl--sync-decision
+ local-md stored (plist-get remote :description))
+ (:noop (message "%s already matches Linear" issue-id))
+ (:conflict
+ (pearl--resolve-conflict
+ (format "%s description" issue-id)
+ local-md (plist-get remote :description)
+ marker "LINEAR-DESC-SHA256"
+ (lambda (md)
+ (org-with-point-at marker
+ (pearl--set-entry-body-at-point (pearl--md-to-org md))))
+ (lambda (md cb)
+ (pearl--update-issue-description-async
+ issue-id md
+ (lambda (r) (funcall cb (plist-get r :success)))))))
+ (:push
+ (pearl--update-issue-description-async
+ issue-id local-md
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (org-entry-put marker "LINEAR-DESC-SHA256"
+ (secure-hash 'sha256 local-md))
+ (when (plist-get result :updated-at)
+ (org-entry-put marker "LINEAR-DESC-UPDATED-AT"
+ (plist-get result :updated-at)))
+ (message "Synced description for %s to Linear" issue-id)
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to sync description for %s" issue-id))))))))))))))
+
+;;;###autoload
+(defun pearl-sync-current-issue-title ()
+ "Push the title edited in the heading of the issue at point to Linear.
+A separate path from the description sync, sharing the same conflict gate and
+working from anywhere inside an issue subtree. Note the title is lossy: the
+renderer strips square brackets, so the heading holds the stripped form and a
+push sends that stripped title. Gated like the description sync: unchanged
+title sends nothing; a local edit against an unchanged remote pushes and
+advances the title provenance; both-changed refuses and reports the conflict."
+ (interactive)
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (stored (org-entry-get nil "LINEAR-TITLE-SHA256"))
+ (marker (point-marker)))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (let ((local-title (pearl--issue-title-at-point)))
+ (if (string= (secure-hash 'sha256 local-title) (or stored ""))
+ (message "No title changes to sync for %s" issue-id)
+ (pearl--progress "Checking Linear for remote title changes...")
+ (pearl--fetch-issue-title-async
+ issue-id
+ (lambda (remote)
+ (if (null remote)
+ (message "Could not fetch %s from Linear; not syncing" issue-id)
+ (pcase (pearl--sync-decision
+ local-title stored (plist-get remote :title))
+ (:noop (message "%s title already matches Linear" issue-id))
+ (:conflict
+ (pearl--resolve-conflict
+ (format "%s title" issue-id)
+ local-title (plist-get remote :title)
+ marker "LINEAR-TITLE-SHA256"
+ (lambda (md)
+ (org-with-point-at marker
+ (org-back-to-heading t)
+ (org-edit-headline md)))
+ (lambda (md cb)
+ (pearl--update-issue-title-async
+ issue-id md
+ (lambda (r) (funcall cb (plist-get r :success)))))))
+ (:push
+ (pearl--update-issue-title-async
+ issue-id local-title
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (org-entry-put marker "LINEAR-TITLE-SHA256"
+ (secure-hash 'sha256 local-title))
+ (message "Synced title for %s to Linear" issue-id)
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to sync title for %s" issue-id))))))))))))))
+
+(defun pearl--replace-issue-subtree-at-point (issue)
+ "Replace the issue subtree at point with a freshly formatted ISSUE entry.
+ISSUE is a normalized issue plist. The whole subtree (heading, drawer, body,
+and any comment children) is rewritten from the entry, so the rendered result
+matches a first fetch."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((beg (point))
+ (end (save-excursion (org-end-of-subtree t t) (point))))
+ (delete-region beg end)
+ (goto-char beg)
+ (insert (pearl--format-issue-as-org-entry issue))
+ ;; Close the rewritten subtree's drawer(s) but leave the issue itself
+ ;; expanded -- a single-issue refresh keeps the user on the issue they
+ ;; were looking at, unlike a full repopulation which re-folds the page.
+ (when pearl-fold-after-update
+ (save-excursion
+ (goto-char beg)
+ (save-restriction
+ (org-narrow-to-subtree)
+ (pearl--hide-all-drawers)))))))
+
+;;;###autoload
+(defun pearl-refresh-current-issue ()
+ "Re-fetch the issue at point from Linear and rewrite its subtree in place.
+Works from anywhere inside an issue subtree. If the description body has
+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)
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (stored (org-entry-get nil "LINEAR-DESC-SHA256"))
+ (marker (point-marker)))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (let ((local-md (pearl--org-to-md (pearl--issue-body-at-point))))
+ ;; Stash an unpushed edit before the overwrite rather than refusing,
+ ;; so an explicit single-issue refresh always proceeds without data loss.
+ (unless (string= (secure-hash 'sha256 local-md) (or stored ""))
+ (pearl--stash-conflict-text
+ (format "%s description (pre-refresh)" issue-id) local-md))
+ (pearl--progress "Refreshing %s from Linear..." issue-id)
+ (pearl--fetch-issue-async
+ issue-id
+ (lambda (result)
+ (pcase result
+ ((or :error (pred null))
+ (message "Linear returned an error fetching %s; not refreshing" issue-id))
+ (:missing
+ (message "Issue %s is no longer on Linear (deleted or no access); not refreshing"
+ issue-id))
+ (raw
+ (save-excursion
+ (goto-char marker)
+ (pearl--replace-issue-subtree-at-point
+ (pearl--normalize-issue raw)))
+ (pearl-highlight-comments)
+ (pearl--surface-buffer (marker-buffer marker))
+ (message "Refreshed %s from Linear" issue-id)))))))))
+
+(defun pearl--create-comment-async (issue-id body callback)
+ "Create a comment with BODY on ISSUE-ID via commentCreate.
+CALLBACK is called with the normalized comment plist on success, or nil on a
+GraphQL/transport failure or a non-success payload."
+ (let ((query "mutation CommentCreate($issueId: String!, $body: String!) {
+ commentCreate(input: {issueId: $issueId, body: $body}) {
+ success
+ comment {
+ id body createdAt
+ user { id name displayName }
+ botActor { name }
+ externalUser { name }
+ }
+ }
+ }")
+ (variables `(("issueId" . ,issue-id) ("body" . ,body))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let* ((payload (cdr (assoc 'commentCreate (assoc 'data data))))
+ (success (eq t (cdr (assoc 'success payload))))
+ (comment (cdr (assoc 'comment payload))))
+ (funcall callback
+ (and success comment (pearl--normalize-comment comment)))))
+ (lambda (_error _response _data) (funcall callback nil)))))
+
+(defun pearl--append-comment-to-issue (comment)
+ "Insert COMMENT (a normalized plist) under the issue subtree at point.
+Appends after any existing comments in the issue's `Comments' subtree, creating
+that subtree at the end of the issue when it does not exist yet."
+ (save-excursion
+ (org-back-to-heading t)
+ (let* ((issue-end (save-excursion (org-end-of-subtree t t) (point)))
+ (comments-pos
+ (save-excursion
+ (when (re-search-forward "^\\*+ Comments[ \t]*$" issue-end t)
+ (match-beginning 0)))))
+ (if comments-pos
+ (progn
+ (goto-char comments-pos)
+ (org-end-of-subtree t t)
+ (insert (pearl--format-comment comment)))
+ (goto-char issue-end)
+ (insert "*** Comments\n" (pearl--format-comment comment))))))
+
+;;;###autoload
+(defun pearl-add-comment (body)
+ "Add a comment with BODY to the Linear issue at point and insert it.
+Works from anywhere inside an issue subtree. The new comment is the viewer's
+own, so it renders editable; edit it later with
+`pearl-edit-current-comment'."
+ (interactive "sComment: ")
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (marker (point-marker)))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (pearl--progress "Adding comment to %s..." issue-id)
+ (pearl--create-comment-async
+ issue-id body
+ (lambda (comment)
+ (if (null comment)
+ (message "Failed to add comment to %s" issue-id)
+ (save-excursion
+ (goto-char marker)
+ (pearl--append-comment-to-issue comment))
+ (pearl-highlight-comments)
+ (pearl--surface-buffer (marker-buffer marker))
+ (message "Added comment to %s" issue-id)))))))
+
+;;;###autoload
+(defun pearl-open-current-issue ()
+ "Open the Linear issue at point in the browser.
+Reads the `LINEAR-URL' property of the enclosing issue heading and hands it to
+`browse-url'. Works from anywhere inside the issue subtree."
+ (interactive)
+ (let ((url (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (org-entry-get nil "LINEAR-URL"))))
+ (unless (and url (not (string-empty-p url)))
+ (user-error "No LINEAR-URL on the issue at point"))
+ (browse-url url)))
+
+(defun pearl--delete-issue-async (issue-id callback)
+ "Delete ISSUE-ID on Linear via issueDelete, calling CALLBACK with the outcome.
+CALLBACK receives a plist (:success BOOL). Linear's `issueDelete' is a soft
+delete: it moves the issue to Trash (recoverable for about 30 days), not a
+permanent purge."
+ (let ((query "mutation IssueDelete($id: String!) {
+ issueDelete(id: $id) { success }
+ }")
+ (variables `(("id" . ,issue-id))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let ((payload (cdr (assoc 'issueDelete (assoc 'data data)))))
+ (funcall callback
+ (list :success (eq t (cdr (assoc 'success payload)))))))
+ (lambda (_error _response _data) (funcall callback '(:success nil))))))
+
+;;;###autoload
+(defun pearl-delete-current-issue ()
+ "Delete the Linear issue at point after confirmation, removing its subtree.
+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)
+ (unless (save-excursion (ignore-errors (org-back-to-heading t) t))
+ (user-error "Not on a Linear issue heading"))
+ (let* ((marker (save-excursion (org-back-to-heading t) (point-marker)))
+ (issue-id (org-entry-get marker "LINEAR-ID"))
+ (ident (or (org-entry-get marker "LINEAR-IDENTIFIER") "this issue")))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (when (yes-or-no-p (format "Delete %s from Linear (moves it to Trash)? " ident))
+ (pearl--delete-issue-async
+ issue-id
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (save-excursion
+ (goto-char marker)
+ (org-back-to-heading t)
+ (delete-region (point)
+ (progn (org-end-of-subtree t t) (point))))
+ (message "Deleted %s (moved to Trash on Linear)" ident)
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to delete %s" ident)))))))
+
+;;; Command-managed Drawer Fields
+
+(defun pearl--update-issue-async (issue-id input callback)
+ "Push INPUT (an `IssueUpdateInput' alist) to ISSUE-ID via issueUpdate.
+INPUT is an alist of field names to values, such as a single
+\"priority\"/2 or \"stateId\"/id pair, json-encoded into the input object.
+CALLBACK is called with a plist of :success BOOL and :updated-at STR. This is
+the generic mutation the field commands share."
+ (let ((query "mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
+ issueUpdate(id: $id, input: $input) {
+ success
+ issue { id updatedAt }
+ }
+ }")
+ (variables `(("id" . ,issue-id) ("input" . ,input))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let* ((payload (cdr (assoc 'issueUpdate (assoc 'data data))))
+ (success (eq t (cdr (assoc 'success payload))))
+ (issue (cdr (assoc 'issue payload))))
+ (funcall callback
+ (list :success success
+ :updated-at (cdr (assoc 'updatedAt issue))))))
+ (lambda (_error _response _data) (funcall callback '(:success nil))))))
+
+(defconst pearl--priority-choices
+ '(("None" . 0) ("Urgent" . 1) ("High" . 2) ("Medium" . 3) ("Low" . 4))
+ "Linear priority names mapped to their numeric API values.")
+
+(defun pearl--set-priority-cookie (priority-num)
+ "Set the Org priority cookie on the heading at point from PRIORITY-NUM.
+1-4 map to #A-#D; 0 (None) removes the cookie. The priority range is bound
+locally so #D is accepted regardless of the user's `org-priority' settings."
+ (save-excursion
+ (org-back-to-heading t)
+ (let ((org-priority-highest ?A)
+ (org-priority-lowest ?D))
+ (pcase priority-num
+ (1 (org-priority ?A))
+ (2 (org-priority ?B))
+ (3 (org-priority ?C))
+ (4 (org-priority ?D))
+ (_ (org-priority 'remove))))))
+
+;;;###autoload
+(defun pearl-set-priority (priority-name)
+ "Set the priority of the Linear issue at point to PRIORITY-NAME.
+Interactively, completes over None/Urgent/High/Medium/Low. Pushes the numeric
+priority to Linear and rewrites the heading cookie on success. Works from
+anywhere inside an issue subtree."
+ (interactive
+ (list (completing-read "Priority: " pearl--priority-choices nil t)))
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (priority-num (cdr (assoc priority-name pearl--priority-choices)))
+ (marker (point-marker)))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (unless priority-num
+ (user-error "Unknown priority: %s" priority-name))
+ (pearl--update-issue-async
+ issue-id `(("priority" . ,priority-num))
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (save-excursion
+ (goto-char marker)
+ (pearl--set-priority-cookie priority-num))
+ (message "Set %s priority to %s" issue-id priority-name)
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to set priority for %s" issue-id)))))))
+
+(defun pearl--set-heading-state (state-name state-id)
+ "Update the heading at point to STATE-NAME / STATE-ID.
+Rewrites the TODO keyword (mapped from STATE-NAME) and the LINEAR-STATE-NAME /
+LINEAR-STATE-ID drawer properties. The Linear org-todo sync hook is inhibited
+during the keyword change so updating the keyword here does not trigger a
+second push back to Linear."
+ (save-excursion
+ (org-back-to-heading t)
+ (org-entry-put nil "LINEAR-STATE-NAME" state-name)
+ (org-entry-put nil "LINEAR-STATE-ID" state-id)
+ (let ((org-after-todo-state-change-hook nil))
+ (org-todo (pearl--map-linear-state-to-org state-name)))))
+
+;;;###autoload
+(defun pearl-set-state (state-name)
+ "Set the workflow state of the Linear issue at point to STATE-NAME.
+Interactively, completes over the issue team's workflow states. Pushes the
+resolved state id to Linear and updates the heading keyword and drawer on
+success. Works from anywhere inside an issue subtree."
+ (interactive
+ (let ((team-id (save-excursion
+ (when (ignore-errors (org-back-to-heading t) t)
+ (org-entry-get nil "LINEAR-TEAM-ID")))))
+ (list (completing-read
+ "State: "
+ (mapcar (lambda (s) (cdr (assoc 'name s)))
+ (and team-id (pearl--team-states team-id)))
+ nil t))))
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let* ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (team-id (org-entry-get nil "LINEAR-TEAM-ID"))
+ (marker (point-marker))
+ (states (and team-id (pearl--team-states team-id)))
+ (state (seq-find (lambda (s) (string= (cdr (assoc 'name s)) state-name))
+ states))
+ (state-id (and state (cdr (assoc 'id state)))))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (unless state-id
+ (user-error "No workflow state named %s in this team" state-name))
+ (pearl--update-issue-async
+ issue-id `(("stateId" . ,state-id))
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (save-excursion
+ (goto-char marker)
+ (pearl--set-heading-state state-name state-id))
+ (message "Set %s state to %s" issue-id state-name)
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to set state for %s" issue-id)))))))
+
+(defun pearl--team-collection-names (kind team-id)
+ "Return the display labels of the KIND collection for TEAM-ID, for completion."
+ (mapcar (lambda (n) (pearl--node-label kind n))
+ (and team-id (pearl--team-collection kind team-id))))
+
+;;;###autoload
+(defun pearl-set-assignee (assignee-name)
+ "Set the assignee of the Linear issue at point to ASSIGNEE-NAME.
+Interactively, completes over the issue team's members. Resolves the name to
+a user id, pushes it, and updates the assignee drawer on success. Works from
+anywhere inside an issue subtree."
+ (interactive
+ (let ((team-id (save-excursion
+ (when (ignore-errors (org-back-to-heading t) t)
+ (org-entry-get nil "LINEAR-TEAM-ID")))))
+ (list (completing-read "Assignee: "
+ (pearl--team-collection-names 'members team-id)
+ nil t))))
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let* ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (team-id (org-entry-get nil "LINEAR-TEAM-ID"))
+ (marker (point-marker))
+ (assignee-id (and team-id
+ (pearl--resolve-team-id 'members assignee-name team-id))))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (unless assignee-id
+ (user-error "No team member matching %s" assignee-name))
+ (pearl--update-issue-async
+ issue-id `(("assigneeId" . ,assignee-id))
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (save-excursion
+ (goto-char marker)
+ (org-entry-put nil "LINEAR-ASSIGNEE-NAME" assignee-name)
+ (org-entry-put nil "LINEAR-ASSIGNEE-ID" assignee-id))
+ (message "Set %s assignee to %s" issue-id assignee-name)
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to set assignee for %s" issue-id)))))))
+
+;;;###autoload
+(defun pearl-set-labels (label-names)
+ "Set the labels of the Linear issue at point to LABEL-NAMES.
+Interactively, completes (multiple) over the issue team's labels; an empty
+selection clears the labels. Resolves each name to a label id, pushes the id
+list, and updates the labels drawer on success. Works from anywhere inside an
+issue subtree."
+ (interactive
+ (let ((team-id (save-excursion
+ (when (ignore-errors (org-back-to-heading t) t)
+ (org-entry-get nil "LINEAR-TEAM-ID")))))
+ (list (completing-read-multiple
+ "Labels (comma-separated, empty to clear): "
+ (pearl--team-collection-names 'labels team-id)))))
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear issue heading"))
+ (let* ((issue-id (org-entry-get nil "LINEAR-ID"))
+ (team-id (org-entry-get nil "LINEAR-TEAM-ID"))
+ (marker (point-marker))
+ (label-ids (mapcar
+ (lambda (name)
+ (or (and team-id
+ (pearl--resolve-team-id 'labels name team-id))
+ (user-error "No label matching %s" name)))
+ label-names)))
+ (unless issue-id
+ (user-error "Not on a Linear issue heading"))
+ (pearl--update-issue-async
+ issue-id `(("labelIds" . ,label-ids))
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (save-excursion
+ (goto-char marker)
+ (org-entry-put nil "LINEAR-LABELS"
+ (format "[%s]" (mapconcat #'identity label-names ", "))))
+ (message "Set %s labels to %s" issue-id
+ (if label-names (mapconcat #'identity label-names ", ") "none"))
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to set labels for %s" issue-id)))))))
+
+;;; User-facing Commands (Async)
+
+(defun pearl--source-name (source)
+ "Return the display name of a SOURCE descriptor, or a default."
+ (or (plist-get source :name) "Linear issues"))
+
+(defun pearl--summarize-filter (filter)
+ "Return a short human-readable summary of a FILTER authoring plist.
+An empty FILTER summarizes as \"all issues\"."
+ (if (null filter)
+ "all issues"
+ (let (parts)
+ (cl-loop for (key val) on filter by #'cddr do
+ (push (pcase key
+ (:assignee (format "assignee: %s"
+ (if (eq val :me) "me" val)))
+ (:open (if val "open" "any state"))
+ (:labels (format "labels: %s"
+ (mapconcat #'identity
+ (if (listp val) val (list val))
+ ", ")))
+ (_ (format "%s: %s" (substring (symbol-name key) 1) val)))
+ parts))
+ (mapconcat #'identity (nreverse parts) ", "))))
+
+(defun pearl--read-active-source ()
+ "Read the active source descriptor from the current buffer's header, or nil.
+Parses the `#+LINEAR-SOURCE:' keyword that `pearl--build-org-content'
+writes."
+ (save-excursion
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+LINEAR-SOURCE: \\(.*\\)$" nil t)
+ (ignore-errors (car (read-from-string (match-string 1)))))))
+
+(defun pearl--build-org-content (issues &optional source truncated)
+ "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. 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)))
+ (with-temp-buffer
+ (insert (format "#+title: Linear — %s\n" name))
+ (insert "#+STARTUP: show3levels\n")
+ (insert (format "#+TODO: %s\n"
+ (if (bound-and-true-p org-todo-keywords)
+ (let ((seq (car org-todo-keywords)))
+ (mapconcat #'identity (cdr seq) " "))
+ "TODO IN-PROGRESS IN-REVIEW BACKLOG BLOCKED | DONE")))
+ ;; Source-tracking metadata: the serialized source drives refresh; the
+ ;; rest is human-readable provenance.
+ (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)))
+ (insert (format "#+LINEAR-COUNT: %d\n" (length issues)))
+ (insert (format "#+LINEAR-TRUNCATED: %s\n" (if truncated "yes" "no")))
+ ;; Affordance preamble (org comments -- not rendered content).
+ (insert "#\n")
+ (insert "# Body = the issue description; edit it, then M-x pearl-sync-current-issue to push.\n")
+ (insert "# Comments subtree = the thread; add with M-x pearl-add-comment.\n")
+ (insert "# Drawer fields change via M-x pearl-set-priority / -state / -assignee / -labels.\n")
+ (insert "# Refresh with M-x pearl-refresh-current-view (whole file) or -current-issue (one).\n")
+ (insert "\n")
+
+ ;; Single top-level parent so the issues are sortable as a group
+ ;; (org-sort on this heading) instead of orphan headings, and so a
+ ;; show3levels fold has a level-1 root. Named after the view.
+ (insert (format "* %s\n" name))
+
+ (dolist (issue issues)
+ (insert (pearl--format-issue-as-org-entry issue)))
+
+ (buffer-string))))
+
+(defun pearl--update-org-from-issues (issues &optional source truncated)
+ "Update `pearl-org-file-path' with rendered ISSUES, buffer-aware.
+SOURCE and TRUNCATED are threaded into the header (see
+`pearl--build-org-content').
+
+Behavior depends on whether the file is currently visited in a buffer:
+
+- No buffer visiting the file: write atomically to disk via `with-temp-file',
+ then visit it so there is a buffer to show.
+- Buffer exists and is unmodified: replace its contents in place and save,
+ preserving point and avoiding a modtime mismatch warning.
+- Buffer exists and has unsaved edits: do not overwrite. Log and emit a
+ message asking the user to save or revert before re-running.
+
+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)
+ (new-content (pearl--build-org-content issues source truncated))
+ (existing-buf (find-buffer-visiting org-file-path)))
+ (cond
+ ;; Branch A: no buffer visits the file -- atomic file write, then visit.
+ ((not existing-buf)
+ (pearl--log "Writing %d issues to %s (no buffer)"
+ (length issues) org-file-path)
+ (make-directory (file-name-directory org-file-path) t)
+ (with-temp-file org-file-path
+ (insert new-content))
+ (message "Updated Linear issues in %s with %d active issues"
+ org-file-path (length issues))
+ (let ((buf (find-file-noselect org-file-path)))
+ (with-current-buffer buf (pearl--restore-page-visibility))
+ (pearl--surface-buffer buf)))
+
+ ;; Branch B: buffer exists and is clean -- replace contents in place.
+ ((not (buffer-modified-p existing-buf))
+ (pearl--log "Writing %d issues to %s (clean buffer)"
+ (length issues) org-file-path)
+ (with-current-buffer existing-buf
+ (let ((recorded-point (point))
+ (inhibit-read-only t))
+ (erase-buffer)
+ (insert new-content)
+ (save-buffer)
+ (goto-char (min recorded-point (point-max)))
+ (pearl--restore-page-visibility)))
+ (message "Updated Linear issues in %s with %d active issues"
+ org-file-path (length issues))
+ (pearl--surface-buffer existing-buf))
+
+ ;; Branch C: buffer is dirty -- defer, do not overwrite, but show it.
+ (t
+ (pearl--log
+ "Linear refresh deferred: %s has unsaved changes (%d issues not written)"
+ org-file-path (length issues))
+ (message
+ "Linear refresh deferred: %s has unsaved changes. Save or revert, then re-run M-x pearl-list-issues."
+ (file-name-nondirectory org-file-path))
+ (pearl--surface-buffer existing-buf)))))
+
+(defcustom pearl-saved-queries nil
+ "Named local issue queries, run with `pearl-run-saved-query'.
+Each entry is (NAME . SPEC) where SPEC is a plist with `:filter' (an authoring
+filter plist), and optional `:sort' (`updated', `created', `priority', or
+`title') and `:order' (`asc' or `desc', default `desc'). AND-only in v1; use a
+Linear Custom View for OR logic."
+ :type '(alist :key-type string :value-type plist)
+ :group 'pearl)
+
+(defun pearl--sort-issues (issues sort order)
+ "Return ISSUES sorted by SORT (a symbol) in ORDER (`asc' or `desc').
+SORT is one of `updated', `priority', `title', or nil (no client-side sort).
+ORDER defaults to descending. Sorting happens after fetch so a refresh always
+produces the same heading order rather than reshuffling into noise."
+ (let ((key (pcase sort
+ ('updated (lambda (i) (or (plist-get i :updated-at) "")))
+ ('priority (lambda (i) (or (plist-get i :priority) 99)))
+ ('title (lambda (i) (downcase (or (plist-get i :title) ""))))
+ (_ nil))))
+ (if (null key)
+ issues
+ (let* ((lessp (if (eq sort 'priority)
+ (lambda (a b) (< (funcall key a) (funcall key b)))
+ (lambda (a b) (string< (funcall key a) (funcall key b)))))
+ (ascending (sort (copy-sequence issues) lessp)))
+ (if (eq order 'asc) ascending (nreverse ascending))))))
+
+(defun pearl--sort->order-by (sort)
+ "Map a SORT symbol to the server `orderBy' value.
+`created' uses `createdAt'; everything else (including `updated') uses
+`updatedAt', the only other field Linear's public ordering supports."
+ (if (eq sort 'created) 'createdAt 'updatedAt))
+
+;;;###autoload
+(defun pearl-run-saved-query (name)
+ "Run the saved query NAME from `pearl-saved-queries'.
+Interactively, completes over the configured query names. Compiles the stored
+filter, fetches, sorts per the query's `:sort'/`:order', and renders into the
+active file with the query recorded as the source."
+ (interactive
+ (list (completing-read "Saved query: "
+ (mapcar #'car pearl-saved-queries) nil t)))
+ (let ((entry (assoc name pearl-saved-queries)))
+ (unless entry
+ (user-error "No saved query named %s" name))
+ (let* ((spec (cdr entry))
+ (filter-plist (plist-get spec :filter))
+ (sort (plist-get spec :sort))
+ (order (plist-get spec :order))
+ (source (list :type 'filter :name name :filter filter-plist
+ :sort sort :order order)))
+ (pearl--progress "Running saved query %s..." name)
+ (pearl--query-issues-async
+ (pearl--build-issue-filter filter-plist)
+ (lambda (result) (pearl--render-query-result result source))
+ (pearl--sort->order-by sort)))))
+
+(defun pearl--assemble-filter (team open state project labels assignee)
+ "Assemble a filter authoring plist from the chosen dimensions.
+The non-nil of TEAM, OPEN, STATE, PROJECT, LABELS, and ASSIGNEE appear (an
+empty LABELS list is dropped), feeding `pearl--build-issue-filter'
+with just what the user set."
+ (append
+ (when team (list :team team))
+ (when assignee (list :assignee assignee))
+ (when open (list :open t))
+ (when state (list :state state))
+ (when project (list :project project))
+ (when labels (list :labels labels))))
+
+(defun pearl--read-filter-interactively ()
+ "Build a filter plist by completing over the chosen team's fetched dimensions.
+Picks a team first (scoping the rest), then offers open-only, state, project,
+labels, and assignee. Empty answers drop the dimension."
+ (let* ((teams (pearl--all-teams))
+ (team-name (completing-read "Team (empty for any): "
+ (mapcar (lambda (tm) (cdr (assoc 'name tm))) teams)
+ nil nil))
+ (team-id (and (not (string-empty-p team-name))
+ (pearl--get-team-id-by-name team-name)))
+ (open (y-or-n-p "Open issues only? "))
+ (state (and team-id
+ (let ((s (completing-read
+ "State (empty for any): "
+ (mapcar (lambda (st) (cdr (assoc 'name st)))
+ (pearl--team-states team-id))
+ nil nil)))
+ (unless (string-empty-p s) s))))
+ (project (and team-id
+ (let ((p (completing-read
+ "Project (empty for any): "
+ (pearl--team-collection-names 'projects team-id)
+ nil nil)))
+ (unless (string-empty-p p) p))))
+ (labels (and team-id
+ (completing-read-multiple
+ "Labels (comma-separated, empty for none): "
+ (pearl--team-collection-names 'labels team-id))))
+ (assignee (pcase (completing-read "Assignee (me / any): " '("me" "any") nil t "any")
+ ("me" :me)
+ (_ nil))))
+ (pearl--assemble-filter
+ (and (not (string-empty-p team-name)) team-name)
+ open state project labels assignee)))
+
+(defun pearl--save-query (name filter-plist &optional sort order)
+ "Save FILTER-PLIST as the saved query NAME, replacing any entry of that NAME.
+Persists `pearl-saved-queries' via Customize. SORT and ORDER are
+stored when given."
+ (let ((entry (cons name (append (list :filter filter-plist)
+ (when sort (list :sort sort))
+ (when order (list :order order))))))
+ (setq pearl-saved-queries
+ (cons entry (assoc-delete-all name (copy-sequence pearl-saved-queries))))
+ (ignore-errors
+ (customize-save-variable 'pearl-saved-queries pearl-saved-queries))))
+
+;;;###autoload
+(defun pearl-list-issues-filtered (filter-plist &optional save-name)
+ "Build an ad-hoc issue filter interactively, run it, and render it.
+Interactively, completes each dimension from the chosen team's fetched
+projects/states/labels (so a typo can't produce a confusing empty result), and
+offers to save the filter as a local query. FILTER-PLIST is the authoring
+filter; SAVE-NAME, when given, persists it via `pearl--save-query'."
+ (interactive
+ (list (pearl--read-filter-interactively)
+ (when (y-or-n-p "Save as a local query? ")
+ (read-string "Query name: "))))
+ (when (and save-name (not (string-empty-p save-name)))
+ (pearl--save-query save-name filter-plist))
+ (let ((source (list :type 'filter
+ :name (if (and save-name (not (string-empty-p save-name)))
+ save-name
+ "Ad-hoc filter")
+ :filter filter-plist)))
+ (pearl--progress "Running ad-hoc filter...")
+ (pearl--query-issues-async
+ (pearl--build-issue-filter filter-plist)
+ (lambda (result) (pearl--render-query-result result source)))))
+
+(defun pearl--render-query-result (result source)
+ "Render a query RESULT into the active file, tagged with SOURCE.
+Normalizes the raw nodes, writes them via `pearl--update-org-from-issues'
+with SOURCE and the truncation flag, and reports the outcome. The one render
+boundary shared by list-issues, the ad-hoc filter, saved queries, views, and
+refresh."
+ (pcase (pearl--query-result-status result)
+ ('ok
+ (let ((issues (pearl--sort-issues
+ (mapcar #'pearl--normalize-issue
+ (pearl--query-result-issues result))
+ (plist-get source :sort)
+ (plist-get source :order)))
+ (truncated (pearl--query-result-truncated-p result)))
+ (condition-case err
+ (progn
+ (pearl--update-org-from-issues issues source truncated)
+ (message "Linear: wrote %d issue%s%s"
+ (length issues)
+ (if (= 1 (length issues)) "" "s")
+ (if truncated
+ (format " (stopped at the %d-page limit; raise `pearl-max-issue-pages')"
+ pearl-max-issue-pages)
+ "")))
+ (error
+ (pearl--log "Error updating org file: %s" (error-message-string err))
+ (message "Error updating the Linear org file: %s"
+ (error-message-string err))))))
+ ('empty
+ (message "Linear: no issues match %s (file left unchanged)"
+ (pearl--source-name source)))
+ (_
+ (message "Linear: %s"
+ (or (pearl--query-result-message result)
+ "could not fetch issues")))))
+
+(defun pearl--subtree-dirty-p ()
+ "Return non-nil when the issue subtree at point has unpushed body edits.
+Compares the current Org body against `LINEAR-DESC-ORG-SHA256', the hash of the
+body as it was rendered at the last fetch. This is an Org-to-Org comparison: it
+does not round-trip through markdown, so a description whose markdown does not
+survive md->org->md (headings, single-asterisk emphasis) is not mistaken for a
+local edit. This is what lets a refresh protect a locally edited description
+from being clobbered while still re-rendering the untouched ones.
+
+For subtrees rendered before this hash existed (no `LINEAR-DESC-ORG-SHA256'),
+fall back to the older markdown-round-trip comparison; those migrate to the
+Org-baseline hash the next time the subtree is re-rendered."
+ (let ((org-hash (org-entry-get nil "LINEAR-DESC-ORG-SHA256")))
+ (if org-hash
+ (not (string= (secure-hash 'sha256 (pearl--issue-body-at-point)) org-hash))
+ (let ((stored (org-entry-get nil "LINEAR-DESC-SHA256"))
+ (local-md (pearl--org-to-md (pearl--issue-body-at-point))))
+ (not (string= (secure-hash 'sha256 local-md) (or stored "")))))))
+
+(defun pearl--issue-subtree-markers ()
+ "Return an alist of (LINEAR-ID . marker) for every issue heading in the buffer.
+Each marker sits at the start of an issue heading -- one carrying its own
+`LINEAR-ID' property. Comment headings carry `LINEAR-COMMENT-ID' instead and
+have no `LINEAR-ID', so they are skipped. Markers (not raw positions) so they
+track correctly across the in-place inserts and deletes the merge performs."
+ (let (markers)
+ (save-excursion
+ (goto-char (point-min))
+ (while (re-search-forward "^\\*+ " nil t)
+ (save-excursion
+ (beginning-of-line)
+ (let ((id (org-entry-get nil "LINEAR-ID")))
+ (when (and id (not (string-empty-p id)))
+ ;; Insertion type t matters here: replacing an earlier subtree
+ ;; deletes then reinserts at its start, collapsing a later
+ ;; marker onto that point. A type-nil marker would stay before
+ ;; the reinserted text and get stranded on the wrong heading; a
+ ;; type-t marker advances past it to its own heading.
+ (push (cons id (copy-marker (point) t)) markers))))))
+ (nreverse markers)))
+
+(defun pearl--merge-issues-into-buffer (issues)
+ "Merge normalized ISSUES into the current buffer by `LINEAR-ID'.
+Same-source refresh semantics: an existing issue still in ISSUES is re-rendered
+in place; an issue new to ISSUES is appended after the last one; an issue no
+longer in ISSUES is dropped. A subtree whose body has unpushed edits (see
+`pearl--subtree-dirty-p') is never overwritten and never dropped -- it is kept
+and counted, so a refresh can't lose un-synced work. Returns a plist of counts
+\(:updated :added :dropped :skipped)."
+ (let ((existing (pearl--issue-subtree-markers))
+ (fetched-ids (mapcar (lambda (i) (plist-get i :id)) issues))
+ (updated 0) (added 0) (dropped 0) (skipped 0))
+ ;; Existing issues still in the result: re-render in place, unless dirty.
+ (dolist (issue issues)
+ (let ((marker (cdr (assoc (plist-get issue :id) existing))))
+ (when marker
+ (save-excursion
+ (goto-char marker)
+ (if (pearl--subtree-dirty-p)
+ (setq skipped (1+ skipped))
+ (pearl--replace-issue-subtree-at-point issue)
+ (setq updated (1+ updated)))))))
+ ;; Existing issues absent from the result: drop them, but keep dirty ones.
+ (dolist (cell existing)
+ (unless (member (car cell) fetched-ids)
+ (save-excursion
+ (goto-char (cdr cell))
+ (if (pearl--subtree-dirty-p)
+ (setq skipped (1+ skipped))
+ (org-back-to-heading t)
+ (delete-region (point) (progn (org-end-of-subtree t t) (point)))
+ (setq dropped (1+ dropped))))))
+ ;; Issues new to the result: append after the last one, in fetched order.
+ (dolist (issue issues)
+ (unless (assoc (plist-get issue :id) existing)
+ (save-excursion
+ (goto-char (point-max))
+ (unless (bolp) (insert "\n"))
+ (insert (pearl--format-issue-as-org-entry issue)))
+ (setq added (1+ added))))
+ (list :updated updated :added added :dropped dropped :skipped skipped)))
+
+(defun pearl--update-source-header (issue-count truncated)
+ "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."
+ (save-excursion
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+LINEAR-RUN-AT: .*$" nil t)
+ (replace-match (format "#+LINEAR-RUN-AT: %s"
+ (format-time-string "%Y-%m-%d %H:%M"))
+ t t))
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+LINEAR-COUNT: .*$" nil t)
+ (replace-match (format "#+LINEAR-COUNT: %d" issue-count) t t))
+ (goto-char (point-min))
+ (when (re-search-forward "^#\\+LINEAR-TRUNCATED: .*$" nil t)
+ (replace-match (format "#+LINEAR-TRUNCATED: %s" (if truncated "yes" "no")) t t))))
+
+(defun pearl--merge-query-result (result source)
+ "Merge a query RESULT into the current buffer by `LINEAR-ID', tagged with SOURCE.
+The same-source refresh counterpart to `pearl--render-query-result': rather than
+replacing the file, it updates issue subtrees in place, appends new matches, and
+drops issues no longer present (protecting unpushed edits per subtree), then
+refreshes the provenance header. An empty result leaves the buffer untouched
+rather than dropping every issue, mirroring the non-destructive empty handling
+of the replace path."
+ (pcase (pearl--query-result-status result)
+ ('ok
+ (let* ((issues (pearl--sort-issues
+ (mapcar #'pearl--normalize-issue
+ (pearl--query-result-issues result))
+ (plist-get source :sort)
+ (plist-get source :order)))
+ (truncated (pearl--query-result-truncated-p result))
+ (counts (pearl--merge-issues-into-buffer issues)))
+ (pearl--update-source-header (length issues) truncated)
+ (pearl-highlight-comments)
+ (pearl--restore-page-visibility)
+ (pearl--surface-buffer (current-buffer))
+ (message "Refreshed %s: %d updated, %d added, %d dropped%s"
+ (pearl--source-name source)
+ (plist-get counts :updated)
+ (plist-get counts :added)
+ (plist-get counts :dropped)
+ (let ((s (plist-get counts :skipped)))
+ (if (> s 0) (format ", %d kept (unpushed edits)" s) "")))))
+ ('empty
+ (message "Linear: %s now matches no issues (file left unchanged)"
+ (pearl--source-name source)))
+ (_
+ (message "Linear: %s"
+ (or (pearl--query-result-message result)
+ "could not refresh issues")))))
+
+;;;###autoload
+(defun pearl-refresh-current-view ()
+ "Re-run the active source recorded in the current file's header and merge it in.
+Reads the `#+LINEAR-SOURCE:' descriptor, re-fetches it, and merges the result
+into this buffer by `LINEAR-ID' (see `pearl--merge-query-result'): existing
+issues update in place, new matches are appended, and issues no longer present
+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)
+ (let ((source (pearl--read-active-source))
+ (buffer (current-buffer)))
+ (unless source
+ (user-error "No Linear source recorded in this file; run a query or view first"))
+ (let ((merge (lambda (result)
+ (when (buffer-live-p buffer)
+ (with-current-buffer buffer
+ (pearl--merge-query-result result source))))))
+ (pcase (plist-get source :type)
+ ('filter
+ (pearl--progress "Refreshing %s..." (pearl--source-name source))
+ (pearl--query-issues-async
+ (pearl--build-issue-filter (plist-get source :filter))
+ merge))
+ ('view
+ (pearl--progress "Refreshing %s..." (pearl--source-name source))
+ (pearl--query-view-async (plist-get source :id) merge))
+ (_ (user-error "Unknown Linear source type: %s" (plist-get source :type)))))))
+
+;;;###autoload
+(defun pearl-run-view (view-name)
+ "Run a Linear Custom View by VIEW-NAME and render it into the active file.
+Interactively, completes over the workspace's Custom Views. The view's own
+filter runs server-side; the result replaces the active file (behind the
+dirty-buffer guard) and records the view as the active source."
+ (interactive
+ (list (completing-read "Custom view: "
+ (mapcar (lambda (v) (cdr (assoc 'name v)))
+ (pearl--custom-views))
+ nil t)))
+ (let* ((view (seq-find (lambda (v) (string= (cdr (assoc 'name v)) view-name))
+ (pearl--custom-views)))
+ (view-id (and view (cdr (assoc 'id view)))))
+ (unless view-id
+ (user-error "No Custom View named %s" view-name))
+ (let ((source (list :type 'view :name view-name :id view-id
+ :url (cdr (assoc 'url view)))))
+ (pearl--progress "Running view %s..." view-name)
+ (pearl--query-view-async
+ view-id
+ (lambda (result) (pearl--render-query-result result source))))))
+
+;;;###autoload
+(defun pearl-open-current-view-in-linear ()
+ "Open the active view's source URL in the browser.
+Reads the recorded source from the file header; errors when the source is not
+a view or has no URL."
+ (interactive)
+ (let* ((source (pearl--read-active-source))
+ (url (plist-get source :url)))
+ (unless (and url (not (string-empty-p url)))
+ (user-error "The active source has no view URL to open"))
+ (browse-url url)))
+
+;;;###autoload
+(defun pearl-list-issues (&optional project-id)
+ "Fetch my open Linear issues into `pearl-org-file-path' and show them.
+With PROJECT-ID, narrow to that project. \"Open\" means any workflow state
+that is not completed, canceled, or duplicate. Runs asynchronously.
+
+Inclusion is server-side via the issue filter; the state mapping only drives
+how each issue's state renders as a TODO keyword."
+ (interactive)
+ (pearl--log "Executing pearl-list-issues")
+ (pearl--progress "Fetching issues from Linear...")
+ (let* ((authoring `(:assignee :me :open t
+ ,@(when project-id (list :project project-id))))
+ (source (list :type 'filter
+ :name (if project-id "My open issues in project" "My open issues")
+ :filter authoring))
+ (filter (pearl--build-issue-filter authoring)))
+ (pearl--query-issues-async
+ filter
+ (lambda (result) (pearl--render-query-result result source)))))
+
+;;;###autoload
+(defun pearl-list-issues-by-project ()
+ "List Linear issues filtered by a selected project.
+Uses async API for better performance."
+ (interactive)
+ (let* ((team (if pearl-default-team-id
+ (list (cons 'id pearl-default-team-id))
+ (pearl-select-team)))
+ (team-id (cdr (assoc 'id team))))
+ (if team-id
+ (let* ((project (pearl-select-project team-id))
+ (project-id (and project (cdr (assoc 'id project)))))
+ (if project-id
+ (progn
+ (message "Fetching issues for project: %s" (cdr (assoc 'name project)))
+ (pearl-list-issues project-id))
+ (message "No project selected")))
+ (message "No team selected"))))
+
+;;;###autoload
+(defun pearl-new-issue ()
+ "Create a new Linear issue with additional attributes."
+ (interactive)
+ ;; Select team first (needed for states, members, etc.)
+ (let* ((team (if pearl-default-team-id
+ (list (cons 'id pearl-default-team-id))
+ (pearl-select-team)))
+ (team-id (cdr (assoc 'id team))))
+
+ (if team-id
+ (let* ((title (read-string "Issue title: "))
+ (description (read-string "Description: "))
+
+ ;; Get workflow states
+ (states (pearl-get-states team-id))
+ (state-options (when states
+ (mapcar (lambda (state)
+ (cons (cdr (assoc 'name state))
+ (cdr (assoc 'id state))))
+ states)))
+ (selected-state (when state-options
+ (cdr (assoc (completing-read "State: " state-options nil t)
+ state-options))))
+
+ ;; Get priorities
+ (priority-options (pearl-get-priorities))
+ (selected-priority (cdr (assoc (completing-read "Priority: " priority-options nil t)
+ priority-options)))
+
+ ;; Get team members for assignee
+ (members (pearl-get-team-members team-id))
+ (assignee-prompt (completing-read
+ "Assignee: "
+ (mapcar #'car members)
+ nil nil nil nil ""))
+ (selected-assignee (unless (string-empty-p assignee-prompt)
+ (cdr (assoc assignee-prompt members))))
+
+ ;; Estimate (points)
+ (estimate (read-string "Estimate (points, leave empty for none): "))
+ (estimate-num (when (and estimate (not (string-empty-p estimate)))
+ (string-to-number estimate)))
+
+ ;; Issue type (label)
+ (issue-types (pearl-get-issue-types team-id))
+ (label-names (mapcar #'car issue-types))
+ ;; Group labels by category (e.g., "Docs", "Feature", etc.)
+ (label-categories (let ((categories (make-hash-table :test 'equal)))
+ (dolist (label label-names)
+ (when-let* ((parts (split-string label " - " t))
+ (category (car parts)))
+ (puthash category
+ (cons label (gethash category categories nil))
+ categories)))
+ categories))
+ (category-names (hash-table-keys label-categories))
+ ;; First select a category, then a specific label
+ (selected-category (completing-read
+ "Label category: "
+ (append '("All") category-names)
+ nil nil nil nil "All"))
+ (filtered-labels (if (string= selected-category "All")
+ label-names
+ (gethash selected-category label-categories nil)))
+ (label-prompt (completing-read
+ (if (string= selected-category "All")
+ "Label (type for fuzzy search): "
+ (format "Label in %s category: " selected-category))
+ filtered-labels
+ nil nil nil nil ""))
+ (matching-labels (when (not (string-empty-p label-prompt))
+ (cl-remove-if-not
+ (lambda (label-name)
+ (string-match-p (regexp-quote label-prompt) label-name))
+ filtered-labels)))
+ (selected-label-name (if (= (length matching-labels) 1)
+ (car matching-labels)
+ (when matching-labels
+ (completing-read "Select specific label: " matching-labels nil t))))
+ (selected-type (when (and selected-label-name (not (string-empty-p selected-label-name)))
+ (cdr (assoc selected-label-name issue-types))))
+
+ ;; Get project
+ (selected-project (pearl-select-project team-id))
+ (selected-project-id (and selected-project (cdr (assoc 'id selected-project))))
+
+ ;; Prepare mutation
+ (query "mutation CreateIssue($input: IssueCreateInput!) {
+ issueCreate(input: $input) {
+ success
+ issue {
+ id
+ identifier
+ title
+ }
+ }
+ }")
+
+ ;; Build input variables
+ (input `(("title" . ,title)
+ ("description" . ,description)
+ ("teamId" . ,team-id)
+ ,@(when selected-state
+ `(("stateId" . ,selected-state)))
+ ,@(when selected-priority
+ `(("priority" . ,selected-priority)))
+ ,@(when selected-assignee
+ `(("assigneeId" . ,selected-assignee)))
+ ,@(when estimate-num
+ `(("estimate" . ,estimate-num)))
+ ,@(when selected-type
+ `(("labelIds" . [,selected-type])))
+ ,@(when selected-project-id
+ `(("projectId" . ,selected-project-id)))))
+
+ (response (pearl--graphql-request query `(("input" . ,input)))))
+
+ (let ((issue (pearl--created-issue response)))
+ (if issue
+ (progn
+ (message "Created issue %s: %s"
+ (cdr (assoc 'identifier issue))
+ (cdr (assoc 'title issue)))
+ issue)
+ (message "Failed to create issue"))))
+
+ (message "No team selected"))))
+
+;;;###autoload
+(defun pearl-test-connection ()
+ "Test the connection to Linear API."
+ (interactive)
+ (pearl--log "Testing connection to Linear API")
+ (pearl--progress "Testing Linear API connection...")
+
+ (let* ((query "query { viewer { id name } }"))
+ (pearl--graphql-request-async
+ query
+ nil
+ (lambda (response)
+ (if response
+ (let ((viewer (assoc 'viewer (assoc 'data response))))
+ (message "Connected to Linear as: %s" (cdr (assoc 'name viewer))))
+ (message "Failed to connect to Linear API")))
+ (lambda (_error _response _data)
+ (message "Failed to connect to Linear API")))))
+
+;;;###autoload
+(defun pearl-toggle-debug ()
+ "Toggle debug logging for Linear API requests."
+ (interactive)
+ (setq pearl-debug (not pearl-debug))
+ (message "Linear debug mode %s" (if pearl-debug "enabled" "disabled")))
+
+;;;###autoload
+(defun pearl-check-setup ()
+ "Check if Linear.el is properly set up."
+ (interactive)
+ (if pearl-api-key
+ (progn
+ (message "API key is set (length: %d). Testing connection..." (length pearl-api-key))
+ (pearl-test-connection))
+ (message "Linear API key is not set. Use M-x customize-variable RET pearl-api-key")))
+
+;;;###autoload
+(defun pearl-load-api-key-from-env ()
+ "Try to load Linear API key from environment variable."
+ (interactive)
+ (let ((env-key (getenv "LINEAR_API_KEY")))
+ (if env-key
+ (progn
+ (setq pearl-api-key env-key)
+ (message "Loaded Linear API key from LINEAR_API_KEY environment variable"))
+ (message "LINEAR_API_KEY environment variable not found or empty"))))
+
+;;; Org Mode Sync Hooks
+
+;;;###autoload
+(defun pearl-enable-org-sync ()
+ "Enable synchronization between org mode and Linear."
+ (interactive)
+ (add-hook 'after-save-hook #'pearl-org-hook-function nil t)
+ (add-hook 'org-after-todo-state-change-hook #'pearl-sync-org-to-linear nil t)
+ (pearl-highlight-comments)
+ (message "Linear-org synchronization enabled"))
+
+;;;###autoload
+(defun pearl-disable-org-sync ()
+ "Disable synchronization between org mode and Linear."
+ (interactive)
+ (remove-hook 'after-save-hook #'pearl-org-hook-function t)
+ (remove-hook 'org-after-todo-state-change-hook #'pearl-sync-org-to-linear t)
+ (message "Linear-org synchronization disabled"))
+
+;;; Comment Editing
+
+(defface pearl-editable-comment
+ '((t :inherit success))
+ "Face for comment headings the current user can edit."
+ :group 'pearl)
+
+(defface pearl-readonly-comment
+ '((t :inherit shadow))
+ "Face for comment headings the current user cannot edit."
+ :group 'pearl)
+
+(defun pearl--comment-editable-p (author-id viewer-id)
+ "Return non-nil when a comment by AUTHOR-ID is editable by VIEWER-ID.
+Editable only when both ids are present and equal; a nil or empty AUTHOR-ID
+\(bot or external comment) is never editable."
+ (and author-id viewer-id
+ (not (string-empty-p author-id))
+ (string= author-id viewer-id)))
+
+(defun pearl--viewer-async (callback)
+ "Resolve the current Linear viewer and call CALLBACK with a plist (:id :name).
+Caches the result in `pearl--cache-viewer'; calls CALLBACK with nil on a
+transport or GraphQL failure."
+ (if pearl--cache-viewer
+ (funcall callback pearl--cache-viewer)
+ (pearl--graphql-request-async
+ "query { viewer { id name } }" nil
+ (lambda (data)
+ (let ((v (cdr (assoc 'viewer (assoc 'data data)))))
+ (funcall callback
+ (when v
+ (setq pearl--cache-viewer
+ (list :id (cdr (assoc 'id v))
+ :name (cdr (assoc 'name v))))))))
+ (lambda (_error _response _data) (funcall callback nil)))))
+
+(defun pearl--fetch-comment-body-async (comment-id callback)
+ "Fetch COMMENT-ID's current body from Linear.
+CALLBACK is called with the markdown body string on success, or nil on error."
+ (let ((query "query CommentBody($id: String!) {
+ comment(id: $id) { body }
+ }")
+ (variables `(("id" . ,comment-id))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let ((comment (cdr (assoc 'comment (assoc 'data data)))))
+ (funcall callback (when comment (or (cdr (assoc 'body comment)) "")))))
+ (lambda (_error _response _data) (funcall callback nil)))))
+
+(defun pearl--update-comment-async (comment-id body callback)
+ "Push BODY as COMMENT-ID's text via commentUpdate.
+CALLBACK is called with a plist (:success BOOL)."
+ (let ((query "mutation UpdateComment($id: String!, $body: String!) {
+ commentUpdate(id: $id, input: {body: $body}) {
+ success
+ comment { id body }
+ }
+ }")
+ (variables `(("id" . ,comment-id) ("body" . ,body))))
+ (pearl--graphql-request-async
+ query variables
+ (lambda (data)
+ (let* ((payload (cdr (assoc 'commentUpdate (assoc 'data data))))
+ (success (eq t (cdr (assoc 'success payload)))))
+ (funcall callback (list :success success))))
+ (lambda (_error _response _data) (funcall callback '(:success nil))))))
+
+(defun pearl--apply-comment-highlights (viewer-id)
+ "Color every comment heading in the buffer by editability for VIEWER-ID.
+The viewer's own comments get `pearl-editable-comment'; all others get
+`pearl-readonly-comment'. Idempotent: clears prior highlights first."
+ (save-excursion
+ (remove-overlays (point-min) (point-max) 'pearl-comment t)
+ (goto-char (point-min))
+ (while (re-search-forward "^\\*+ " nil t)
+ (let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID")))
+ (when comment-id
+ (let* ((author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID"))
+ (face (if (pearl--comment-editable-p author-id viewer-id)
+ 'pearl-editable-comment
+ 'pearl-readonly-comment))
+ (ov (make-overlay (line-beginning-position) (line-end-position))))
+ (overlay-put ov 'pearl-comment t)
+ (overlay-put ov 'face face)))))))
+
+;;;###autoload
+(defun pearl-highlight-comments ()
+ "Color comment headings in the current buffer by who can edit them.
+The viewer's own comments render green (editable); others render greyed. Runs
+after a fetch/refresh and from `pearl-enable-org-sync', and is safe to
+invoke by hand."
+ (interactive)
+ (let ((buffer (current-buffer)))
+ ;; Best-effort: highlighting is a display nicety and must never abort the
+ ;; operation that triggered it (e.g. a missing API key errors in the
+ ;; request layer), so a failure to resolve the viewer just skips coloring.
+ (ignore-errors
+ (pearl--viewer-async
+ (lambda (viewer)
+ (when (and viewer (buffer-live-p buffer))
+ (with-current-buffer buffer
+ (pearl--apply-comment-highlights (plist-get viewer :id)))))))))
+
+;;;###autoload
+(defun pearl-edit-current-comment ()
+ "Push an edit to the comment at point on Linear.
+Works from anywhere inside a comment's subtree. Only the current user's own
+comments are editable: a comment authored by anyone else (or by a bot or
+integration) is refused without a network call. The push is gated like the
+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)
+ (save-excursion
+ (unless (ignore-errors (org-back-to-heading t) t)
+ (user-error "Not on a Linear comment"))
+ (let ((comment-id (org-entry-get nil "LINEAR-COMMENT-ID"))
+ (author-id (org-entry-get nil "LINEAR-COMMENT-AUTHOR-ID"))
+ (stored (org-entry-get nil "LINEAR-COMMENT-SHA256"))
+ (marker (point-marker)))
+ (unless comment-id
+ (user-error "Not on a Linear comment"))
+ ;; `pearl--issue-body-at-point' reads the text after the drawer and before
+ ;; the first child heading -- a comment subtree has that same shape, so the
+ ;; description path's body reader serves the comment body unchanged.
+ (let ((local-md (pearl--org-to-md (pearl--issue-body-at-point))))
+ (if (string= (secure-hash 'sha256 local-md) (or stored ""))
+ (message "No comment changes to sync")
+ (pearl--viewer-async
+ (lambda (viewer)
+ (cond
+ ((null viewer)
+ (message "Could not determine your Linear identity; not editing"))
+ ((not (pearl--comment-editable-p author-id (plist-get viewer :id)))
+ (message "You can only edit your own comments"))
+ (t
+ (pearl--progress "Checking Linear for remote changes...")
+ (pearl--fetch-comment-body-async
+ comment-id
+ (lambda (remote-md)
+ (if (null remote-md)
+ (message "Could not fetch the comment from Linear; not syncing")
+ (pcase (pearl--sync-decision local-md stored remote-md)
+ (:noop (message "Comment already matches Linear"))
+ (:conflict
+ (pearl--resolve-conflict
+ "comment"
+ local-md remote-md marker "LINEAR-COMMENT-SHA256"
+ (lambda (md)
+ (org-with-point-at marker
+ (pearl--set-entry-body-at-point (pearl--md-to-org md))))
+ (lambda (md cb)
+ (pearl--update-comment-async
+ comment-id md
+ (lambda (r) (funcall cb (plist-get r :success)))))))
+ (:push
+ (pearl--update-comment-async
+ comment-id local-md
+ (lambda (result)
+ (if (plist-get result :success)
+ (progn
+ (org-entry-put marker "LINEAR-COMMENT-SHA256"
+ (secure-hash 'sha256 local-md))
+ (message "Synced comment to Linear")
+ (pearl--surface-buffer (marker-buffer marker)))
+ (message "Failed to sync comment"))))))))))))))))))
+
+;;; Transient Menu
+
+;;;###autoload (autoload 'pearl-menu "pearl" nil t)
+(transient-define-prefix pearl-menu ()
+ "Dispatch menu for pearl commands."
+ ["Linear"
+ ["Fetch"
+ ("l" "My open issues" pearl-list-issues)
+ ("p" "By project" pearl-list-issues-by-project)
+ ("f" "Build a filter" pearl-list-issues-filtered)
+ ("v" "Custom view" pearl-run-view)
+ ("Q" "Saved query" pearl-run-saved-query)]
+ ["View"
+ ("g" "Refresh view" pearl-refresh-current-view)
+ ("r" "Refresh issue" pearl-refresh-current-issue)
+ ("b" "Open view in Linear" pearl-open-current-view-in-linear)]
+ ["Issue at point"
+ ("e" "Edit desc -> push" pearl-sync-current-issue)
+ ("t" "Edit title -> push" pearl-sync-current-issue-title)
+ ("s" "Set state" pearl-set-state)
+ ("a" "Set assignee" pearl-set-assignee)
+ ("P" "Set priority" pearl-set-priority)
+ ("L" "Set labels" pearl-set-labels)
+ ("c" "Add comment" pearl-add-comment)
+ ("M" "Edit comment" pearl-edit-current-comment)
+ ("k" "Delete issue" pearl-delete-current-issue)
+ ("o" "Open in browser" pearl-open-current-issue)]
+ ["Create & org-sync"
+ ("n" "New issue" pearl-new-issue)
+ ("E" "Enable org-sync" pearl-enable-org-sync)
+ ("X" "Disable org-sync" pearl-disable-org-sync)
+ ("u" "Push file -> Linear" pearl-sync-org-to-linear)]
+ ["Setup"
+ ("T" "Test connection" pearl-test-connection)
+ ("C" "Check setup" pearl-check-setup)
+ ("!" "Toggle debug" pearl-toggle-debug)
+ ("x" "Clear cache" pearl-clear-cache)]])
+
+(provide 'pearl)
+;;; pearl.el ends here \ No newline at end of file
diff --git a/tests/Makefile b/tests/Makefile
new file mode 100644
index 0000000..a6b6812
--- /dev/null
+++ b/tests/Makefile
@@ -0,0 +1,295 @@
+# Makefile for pearl.el test suite
+# Usage:
+# make test - Run all tests (excluding :slow tagged)
+# make test-all - Run every test, including :slow tagged
+# make test-file FILE=mapping - Run tests in one file
+# make test-one TEST=name - Run one specific test
+# make test-unit - Run unit tests only
+# make test-integration - Run integration tests only
+# make clean - Remove byte-compiled files
+
+# Configuration
+EASK ?= eask
+
+# eask treats the CWD as its workspace and reads .eask/ from there. All
+# eask invocations must run from project root so the project's .eask/
+# is picked up. The (cd "tests/") --eval restores Emacs default-directory
+# so test files' relative paths (../pearl.el, test-bootstrap.el)
+# resolve correctly.
+PROJECT_ROOT := $(abspath ..)
+EMACS_BATCH = cd $(PROJECT_ROOT) && $(EASK) emacs --batch --eval '(cd "tests/")'
+
+# Include local overrides if present (per-machine knobs, not committed)
+-include makefile-local
+
+# Test files
+ALL_TESTS = $(filter-out test-bootstrap.el,$(wildcard test-*.el))
+UNIT_TESTS = $(filter-out test-integration-%.el,$(ALL_TESTS))
+INTEGRATION_TESTS = $(wildcard test-integration-*.el)
+
+# ERT selector that excludes tests tagged :slow. Applied to default
+# test runs so a slow integration suite doesn't dominate the fast
+# feedback path. test-all runs everything; test-one and test-name
+# honor the user-supplied pattern verbatim.
+ERT_FAST_SELECTOR = (ert-run-tests-batch-and-exit '(not (tag :slow)))
+
+# Colors for output (if terminal supports it)
+RED = \033[0;31m
+GREEN = \033[0;32m
+YELLOW = \033[1;33m
+NC = \033[0m
+
+.PHONY: all test test-all test-file test-one test-name test-unit test-integration validate lint clean help check-deps count list
+
+all: test
+
+# Verify eask + installed deps are available
+check-deps:
+ @if ! command -v $(EASK) >/dev/null 2>&1; then \
+ printf "$(RED)Error: eask not found on PATH$(NC)\n"; \
+ echo "Install: npm install -g @emacs-eask/cli"; \
+ echo " or: https://emacs-eask.github.io/Getting-Started/Install-Eask/"; \
+ exit 1; \
+ fi
+ @if [ ! -d $(PROJECT_ROOT)/.eask ]; then \
+ printf "$(YELLOW)Warning: .eask not found — run 'make setup' from project root$(NC)\n"; \
+ exit 1; \
+ fi
+ @$(EMACS_BATCH) -l check-deps.el >$(PROJECT_ROOT)/tests/check-deps-output.log 2>&1 || { \
+ printf "$(RED)Error: required Emacs Lisp test dependencies are missing$(NC)\n"; \
+ cat $(PROJECT_ROOT)/tests/check-deps-output.log; \
+ exit 1; \
+ }
+ @printf "$(GREEN)✓ eask available, required Emacs Lisp deps loadable$(NC)\n"
+
+# Run all tests (excluding :slow)
+test: check-deps
+ @printf "$(YELLOW)Running all tests ($(words $(ALL_TESTS)) files, excluding :slow)...$(NC)\n"
+ @$(MAKE) --no-print-directory test-unit
+ @$(MAKE) --no-print-directory test-integration
+ @printf "$(GREEN)[✓] All tests complete$(NC)\n"
+
+# Run every test, including :slow tagged
+test-all: check-deps
+ @printf "$(YELLOW)Running all tests including :slow ($(words $(ALL_TESTS)) files)...$(NC)\n"
+ @failed=0; \
+ for testfile in $(ALL_TESTS); do \
+ echo " Testing $$testfile..."; \
+ $(EMACS_BATCH) -l ert -l "$$testfile" \
+ --eval '(ert-run-tests-batch-and-exit t)' || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)[✓] All tests passed$(NC)\n"; \
+ else \
+ printf "$(RED)[✗] $$failed test file(s) failed$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run tests in one file
+test-file: check-deps
+ifndef FILE
+ @printf "$(RED)Error: FILE not specified$(NC)\n"
+ @echo "Usage: make test-file FILE=mapping"
+ @echo " make test-file FILE=test-pearl-mapping.el"
+ @exit 1
+endif
+ @TESTFILE=$$(find . -maxdepth 1 -name "*$(FILE)*.el" -type f | head -1 | sed 's|^\./||'); \
+ if [ -z "$$TESTFILE" ]; then \
+ printf "$(RED)Error: No test file matching '$(FILE)' found$(NC)\n"; \
+ exit 1; \
+ fi; \
+ printf "$(YELLOW)Running tests in $$TESTFILE...$(NC)\n"; \
+ $(EMACS_BATCH) -l ert -l "$$TESTFILE" \
+ --eval "$(ERT_FAST_SELECTOR)" 2>&1 | tee $(PROJECT_ROOT)/tests/test-file-output.log; \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)✓ All tests in $$TESTFILE passed!$(NC)\n"; \
+ else \
+ printf "$(RED)✗ Some tests failed.$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run one specific test (fuzzy match by name)
+test-one: check-deps
+ifndef TEST
+ @printf "$(RED)Error: TEST not specified$(NC)\n"
+ @echo "Usage: make test-one TEST=priority"
+ @echo " make test-one TEST=test-pearl-map-priority-urgent"
+ @exit 1
+endif
+ @printf "$(YELLOW)Searching for test matching '$(TEST)'...$(NC)\n"
+ @TESTFILE=$$(grep -l "ert-deftest.*$(TEST)" test-*.el 2>/dev/null | head -1); \
+ if [ -z "$$TESTFILE" ]; then \
+ printf "$(RED)Error: No test matching '$(TEST)' found$(NC)\n"; \
+ exit 1; \
+ fi; \
+ TESTNAME=$$(grep "ert-deftest.*$(TEST)" "$$TESTFILE" | sed 's/^(ert-deftest \([^ ]*\).*/\1/' | head -1); \
+ printf "$(YELLOW)Running test '$$TESTNAME' in $$TESTFILE...$(NC)\n"; \
+ $(EMACS_BATCH) -l ert -l "$$TESTFILE" \
+ --eval "(ert-run-tests-batch-and-exit \"$$TESTNAME\")" 2>&1; \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)✓ Test $$TESTNAME passed!$(NC)\n"; \
+ else \
+ printf "$(RED)✗ Test $$TESTNAME failed.$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run only unit tests (excluding :slow)
+test-unit: check-deps
+ @printf "$(YELLOW)Running unit tests ($(words $(UNIT_TESTS)) files, excluding :slow)...$(NC)\n"
+ @failed=0; \
+ for testfile in $(UNIT_TESTS); do \
+ echo " Testing $$testfile..."; \
+ $(EMACS_BATCH) -l ert -l "$$testfile" \
+ --eval "$(ERT_FAST_SELECTOR)" || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)[✓] All unit tests passed$(NC)\n"; \
+ else \
+ printf "$(RED)[✗] $$failed unit test file(s) failed$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run only integration tests (excluding :slow)
+test-integration: check-deps
+ @printf "$(YELLOW)Running integration tests ($(words $(INTEGRATION_TESTS)) files, excluding :slow)...$(NC)\n"
+ @if [ -z "$(INTEGRATION_TESTS)" ]; then \
+ printf "$(YELLOW) (no integration test files yet)$(NC)\n"; \
+ fi
+ @failed=0; \
+ for testfile in $(INTEGRATION_TESTS); do \
+ echo " Testing $$testfile..."; \
+ $(EMACS_BATCH) -l ert -l "$$testfile" \
+ --eval "$(ERT_FAST_SELECTOR)" || failed=$$((failed + 1)); \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)[✓] All integration tests passed$(NC)\n"; \
+ else \
+ printf "$(RED)[✗] $$failed integration test file(s) failed$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Run tests matching a name pattern (ERT selector)
+test-name: check-deps
+ifndef TEST
+ @printf "$(RED)Error: TEST not specified$(NC)\n"
+ @echo "Usage: make test-name TEST=test-pearl-map"
+ @echo " make test-name TEST='test-pearl-map-*'"
+ @exit 1
+endif
+ @printf "$(YELLOW)Running tests matching pattern: $(TEST)...$(NC)\n"
+ @$(EMACS_BATCH) -l ert \
+ --eval "(dolist (f (directory-files \".\" t \"^test-.*\\\\.el$$\")) (load f))" \
+ --eval '(ert-run-tests-batch-and-exit "$(TEST)")'
+
+# Count tests
+count:
+ @echo "Test file inventory:"
+ @for f in $(ALL_TESTS); do \
+ count=$$(grep -c "^(ert-deftest" "$$f" 2>/dev/null || echo 0); \
+ printf "%3d tests - %s\n" "$$count" "$$f"; \
+ done | sort -rn
+ @total=$$(find . -name "test-*.el" -exec grep -c "^(ert-deftest" {} \; | awk '{sum+=$$1} END {print sum}'); \
+ printf "$(GREEN)Total: $$total tests across $(words $(ALL_TESTS)) files$(NC)\n"
+
+# List all available tests
+list:
+ @echo "Available tests:"
+ @grep -h "^(ert-deftest" test-*.el | sed 's/^(ert-deftest \([^ ]*\).*/ \1/' | sort
+
+# Validate Emacs Lisp syntax (parens balance — no deps needed)
+validate:
+ @printf "$(YELLOW)Validating Emacs Lisp syntax...$(NC)\n"
+ @failed=0; \
+ total=0; \
+ for file in ../pearl.el test-*.el; do \
+ if [ -f "$$file" ] && [ ! -d "$$file" ]; then \
+ total=$$((total + 1)); \
+ output=$$(emacs --batch -Q --eval "(progn \
+ (setq byte-compile-error-on-warn nil) \
+ (find-file \"$$file\") \
+ (condition-case err \
+ (progn \
+ (check-parens) \
+ (message \"✓ $$file - parentheses balanced\")) \
+ (error \
+ (message \"✗ $$file: %s\" (error-message-string err)) \
+ (kill-emacs 1))))" 2>&1 | grep -E '(✓|✗)'); \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)$$output$(NC)\n"; \
+ else \
+ printf "$(RED)$$output$(NC)\n"; \
+ failed=$$((failed + 1)); \
+ fi; \
+ fi; \
+ done; \
+ if [ $$failed -eq 0 ]; then \
+ printf "$(GREEN)✓ All $$total files validated successfully$(NC)\n"; \
+ else \
+ printf "$(RED)✗ $$failed of $$total files failed validation$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Comprehensive linting with elisp-lint (via eask-installed dev dep).
+# Validators disabled and why:
+# - checkdoc: covered by `eask lint checkdoc' as its own MELPA-prep step.
+# - package-lint: covered by `eask lint package' as its own step.
+# - indent-character: project uses spaces; validator defaults to requiring tabs.
+# - fill-column: validator default (70) is stricter than this project wants.
+# - indent: false positives on dash threading macros (`->', `->>').
+lint: check-deps
+ @printf "$(YELLOW)Running elisp-lint...$(NC)\n"
+ @$(EMACS_BATCH) \
+ -l $(PROJECT_ROOT)/pearl.el \
+ --eval "(require 'elisp-lint)" \
+ -f elisp-lint-files-batch \
+ --no-checkdoc \
+ --no-package-lint \
+ --no-indent-character \
+ --no-fill-column \
+ --no-indent \
+ $(PROJECT_ROOT)/pearl.el 2>&1; \
+ if [ $$? -eq 0 ]; then \
+ printf "$(GREEN)✓ Linting completed successfully$(NC)\n"; \
+ else \
+ printf "$(RED)✗ Linting found issues (see above)$(NC)\n"; \
+ exit 1; \
+ fi
+
+# Clean byte-compiled files
+clean:
+ @printf "$(YELLOW)Cleaning byte-compiled files...$(NC)\n"
+ @rm -f *.elc ../*.elc
+ @rm -f check-deps-output.log test-output.log test-file-output.log test-unit-output.log test-integration-output.log
+ @printf "$(GREEN)✓ Cleaned$(NC)\n"
+
+# Show help
+help:
+ @echo "pearl Test Suite Makefile"
+ @echo ""
+ @echo "Usage:"
+ @echo " make test - Run all tests, excluding :slow"
+ @echo " make test-all - Run all tests including :slow"
+ @echo " make test-unit - Run unit tests only (excluding :slow)"
+ @echo " make test-integration - Run integration tests only (excluding :slow)"
+ @echo " make test-file FILE=mapping - Run tests in one file (fuzzy match)"
+ @echo " make test-one TEST=priority - Run one specific test (fuzzy match)"
+ @echo " make test-name TEST=pattern - Run tests matching ERT name pattern"
+ @echo " make validate - Validate Emacs Lisp syntax (parens balance)"
+ @echo " make lint - Comprehensive linting with elisp-lint"
+ @echo " make count - Count tests per file"
+ @echo " make list - List all test names"
+ @echo " make clean - Remove byte-compiled files and logs"
+ @echo " make check-deps - Verify eask + loadable Emacs Lisp deps"
+ @echo " make help - Show this help message"
+ @echo ""
+ @echo "Project-root targets (run from project root):"
+ @echo " make setup - Install all deps via eask"
+ @echo " make compile - Byte-compile pearl.el"
+ @echo " make coverage - Generate simplecov JSON via undercover"
+ @echo ""
+ @echo "Tagging tests as :slow:"
+ @echo " (ert-deftest test-foo () :tags '(:slow) ...) — excluded by default"
+ @echo " Run with 'make test-all' to include them."
+ @echo ""
+ @echo "Environment variables:"
+ @echo " EASK - eask executable (default: eask)"
diff --git a/tests/check-deps.el b/tests/check-deps.el
new file mode 100644
index 0000000..12b5f60
--- /dev/null
+++ b/tests/check-deps.el
@@ -0,0 +1,38 @@
+;;; check-deps.el --- Verify test dependencies are loadable -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Loaded by tests/Makefile's check-deps target after eask has prepared the
+;; test environment. Keep dependency discovery inside Emacs so package.el,
+;; package-vc, Eask, Nix, and pre-populated load-path setups all work the same
+;; way: a dependency is available if Emacs can require it.
+
+;;; Code:
+
+(when noninteractive
+ (package-initialize))
+
+(defconst pearl-check-deps-required-features
+ '(request json dash s org)
+ "Features required by the pearl test suite.")
+
+(defun pearl-check-deps--missing-features ()
+ "Return required test features that cannot be loaded."
+ (let (missing)
+ (dolist (feature pearl-check-deps-required-features (nreverse missing))
+ (unless (require feature nil t)
+ (push feature missing)))))
+
+(let ((missing (pearl-check-deps--missing-features)))
+ (if missing
+ (progn
+ (message "Missing Emacs Lisp test dependencies: %s"
+ (mapconcat #'symbol-name missing ", "))
+ (message "Run `make setup' from the project root, or make these features available on load-path.")
+ (kill-emacs 1))
+ (message "Required Emacs Lisp dependencies are loadable: %s"
+ (mapconcat #'symbol-name pearl-check-deps-required-features ", "))))
+
+;;; check-deps.el ends here
diff --git a/tests/run-coverage-file.el b/tests/run-coverage-file.el
new file mode 100644
index 0000000..9d5c3b3
--- /dev/null
+++ b/tests/run-coverage-file.el
@@ -0,0 +1,50 @@
+;;; run-coverage-file.el --- Undercover setup for per-file coverage runs -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Loaded by `make coverage' before each test file runs, BEFORE
+;; pearl.el is loaded. Instrumenting must happen first so the
+;; subsequent load picks up the instrumented source.
+;;
+;; Coverage data is merged across per-file invocations into a single
+;; simplecov JSON at .coverage/simplecov.json (under the project root).
+
+;;; Code:
+
+(unless (require 'undercover nil t)
+ (message "")
+ (message "ERROR: undercover not installed.")
+ (message "Run 'make setup' to install development dependencies.")
+ (message "")
+ (kill-emacs 1))
+
+;; Resolve project root from this file's location so undercover patterns
+;; and the report-file path don't depend on default-directory at load time.
+(defvar run-coverage--project-root
+ (file-name-directory
+ (directory-file-name
+ (file-name-directory (or load-file-name buffer-file-name))))
+ "Absolute path to the pearl project root.")
+
+;; Force coverage collection in non-CI environments. Must be set after
+;; loading undercover because the library's top-level form
+;; `(setq undercover-force-coverage (getenv "UNDERCOVER_FORCE"))' would
+;; otherwise overwrite the value.
+(setq undercover-force-coverage t)
+
+;; Local runs emit simplecov for whatever local tooling wants it. CI sets
+;; CI=true (GitHub Actions does this automatically), so we emit a coveralls
+;; JSON instead and leave it on disk for the upload action to pick up.
+;; The `undercover' macro splices each configuration list into `(list ,@it)',
+;; which evaluates the elements. Wildcard strings have to stay atoms — using
+;; `(:files ...)' form lets us evaluate `expand-file-name' to an absolute path.
+(undercover (:files (expand-file-name "pearl.el" run-coverage--project-root))
+ (:report-format (if (getenv "CI") 'coveralls 'simplecov))
+ (:report-file (expand-file-name
+ (if (getenv "CI")
+ ".coverage/coveralls.json"
+ ".coverage/simplecov.json")
+ run-coverage--project-root))
+ (:merge-report t)
+ (:send-report nil))
+
+;;; run-coverage-file.el ends here
diff --git a/tests/test-bootstrap.el b/tests/test-bootstrap.el
new file mode 100644
index 0000000..85b26dd
--- /dev/null
+++ b/tests/test-bootstrap.el
@@ -0,0 +1,47 @@
+;;; test-bootstrap.el --- Common test initialization for pearl -*- 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:
+
+;; Shared initialization for all pearl test files.
+;; Handles package setup, dependency loading, and loading the package source.
+;;
+;; Usage: (require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; Code:
+
+;; Initialize package system for batch mode
+(when noninteractive
+ (package-initialize))
+
+(require 'ert)
+
+;; Load dependencies required by pearl
+(require 'request)
+(require 'json)
+(require 'dash)
+(require 's)
+(require 'org)
+(require 'cl-lib)
+
+;; Load pearl from parent directory
+(load (expand-file-name "../pearl.el") nil t)
+
+(provide 'test-bootstrap)
+;;; test-bootstrap.el ends here
diff --git a/tests/test-pearl-adhoc.el b/tests/test-pearl-adhoc.el
new file mode 100644
index 0000000..97ac22e
--- /dev/null
+++ b/tests/test-pearl-adhoc.el
@@ -0,0 +1,106 @@
+;;; test-pearl-adhoc.el --- Tests for the ad-hoc filtered command -*- 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:
+
+;; Tests for the ad-hoc filter command: the pure `--assemble-filter' that
+;; turns chosen dimension values into a filter plist, `--save-query', and
+;; `pearl-list-issues-filtered' running and optionally saving.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+;;; --assemble-filter
+
+(ert-deftest test-pearl-assemble-filter-only-set-keys ()
+ "Only the dimensions that were chosen appear in the filter plist."
+ (let ((f (pearl--assemble-filter nil t nil nil nil nil)))
+ (should (eq t (plist-get f :open)))
+ (should-not (plist-member f :team))
+ (should-not (plist-member f :labels))))
+
+(ert-deftest test-pearl-assemble-filter-full ()
+ "All chosen dimensions land in the plist."
+ (let ((f (pearl--assemble-filter "ENG" t "In Progress" "Foo" '("bug" "p1") :me)))
+ (should (string= "ENG" (plist-get f :team)))
+ (should (eq t (plist-get f :open)))
+ (should (string= "In Progress" (plist-get f :state)))
+ (should (string= "Foo" (plist-get f :project)))
+ (should (equal '("bug" "p1") (plist-get f :labels)))
+ (should (eq :me (plist-get f :assignee)))))
+
+(ert-deftest test-pearl-assemble-filter-empty-labels-omitted ()
+ "An empty label list does not add a :labels key."
+ (let ((f (pearl--assemble-filter nil nil nil nil '() nil)))
+ (should-not (plist-member f :labels))))
+
+;;; --save-query
+
+(ert-deftest test-pearl-save-query-adds-entry ()
+ "Saving a query adds it to the saved-queries variable."
+ (let ((pearl-saved-queries nil))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (&rest _) nil)))
+ (pearl--save-query "My filter" '(:open t :labels ("bug")))
+ (let ((entry (assoc "My filter" pearl-saved-queries)))
+ (should entry)
+ (should (equal '(:open t :labels ("bug")) (plist-get (cdr entry) :filter)))))))
+
+(ert-deftest test-pearl-save-query-replaces-same-name ()
+ "Saving under an existing name replaces that entry rather than duplicating."
+ (let ((pearl-saved-queries '(("Dup" :filter (:open t)))))
+ (cl-letf (((symbol-function 'customize-save-variable) (lambda (&rest _) nil)))
+ (pearl--save-query "Dup" '(:priority 1))
+ (should (= 1 (cl-count "Dup" pearl-saved-queries
+ :key #'car :test #'string=)))
+ (should (equal '(:priority 1)
+ (plist-get (cdr (assoc "Dup" pearl-saved-queries)) :filter))))))
+
+;;; pearl-list-issues-filtered
+
+(ert-deftest test-pearl-list-issues-filtered-runs-with-source ()
+ "Running an ad-hoc filter compiles it and renders with a filter source."
+ (let ((built nil) (rendered-source nil))
+ (cl-letf (((symbol-function 'pearl--build-issue-filter)
+ (lambda (plist) (setq built plist) '((compiled . t))))
+ ((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter cb &optional _ord)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--render-query-result)
+ (lambda (_result source) (setq rendered-source source))))
+ (pearl-list-issues-filtered '(:assignee :me :open t) nil)
+ (should (equal '(:assignee :me :open t) built))
+ (should (eq 'filter (plist-get rendered-source :type)))
+ (should (equal '(:assignee :me :open t) (plist-get rendered-source :filter))))))
+
+(ert-deftest test-pearl-list-issues-filtered-saves-when-named ()
+ "Passing a save name persists the ad-hoc filter as a saved query."
+ (let ((pearl-saved-queries nil))
+ (cl-letf (((symbol-function 'pearl--build-issue-filter) (lambda (_p) nil))
+ ((symbol-function 'pearl--query-issues-async)
+ (lambda (_f cb &optional _o)
+ (funcall cb (pearl--make-query-result 'empty :issues nil))))
+ ((symbol-function 'pearl--render-query-result) (lambda (&rest _) nil))
+ ((symbol-function 'customize-save-variable) (lambda (&rest _) nil)))
+ (pearl-list-issues-filtered '(:open t) "Saved adhoc")
+ (should (assoc "Saved adhoc" pearl-saved-queries)))))
+
+(provide 'test-pearl-adhoc)
+;;; test-pearl-adhoc.el ends here
diff --git a/tests/test-pearl-api.el b/tests/test-pearl-api.el
new file mode 100644
index 0000000..bf4f45e
--- /dev/null
+++ b/tests/test-pearl-api.el
@@ -0,0 +1,90 @@
+;;; test-pearl-api.el --- Tests for pearl core API layer -*- 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:
+
+;; Tests for the GraphQL request layer with `request' stubbed at the HTTP
+;; boundary: the async entry point routes to success/error correctly and
+;; balances the active-request counter; the sync wrapper returns data or nil.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+
+(ert-deftest test-pearl-graphql-request-async-success-routes-to-success-fn ()
+ "A successful response is passed to the success function."
+ (let ((got nil))
+ (testutil-linear-with-response '((data (viewer (name . "Me"))))
+ (pearl--graphql-request-async
+ "query" nil
+ (lambda (data) (setq got data))
+ (lambda (&rest _) (setq got 'error))))
+ (should (equal '((data (viewer (name . "Me")))) got))))
+
+(ert-deftest test-pearl-graphql-request-async-error-routes-to-error-fn ()
+ "A transport error is passed to the error function."
+ (let ((got nil))
+ (testutil-linear-with-error "boom"
+ (pearl--graphql-request-async
+ "query" nil
+ (lambda (_d) (setq got 'success))
+ (lambda (err _r _d) (setq got err))))
+ (should (equal "boom" got))))
+
+(ert-deftest test-pearl-graphql-request-async-balances-active-counter-on-success ()
+ "The active-request counter returns to its starting value after success."
+ (let ((pearl--active-requests 0))
+ (testutil-linear-with-response '((data))
+ (pearl--graphql-request-async "q" nil #'ignore #'ignore))
+ (should (= 0 pearl--active-requests))))
+
+(ert-deftest test-pearl-graphql-request-async-nil-response-does-not-error ()
+ "A transport error with a nil response routes to error-fn without throwing.
+
+The error handler logs the response status code; that read must be guarded
+so a nil response (some transport failures) doesn't crash inside the handler."
+ (let ((pearl-api-key "test-key") (got nil))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (funcall (plist-get args :error)
+ :error-thrown "boom" :response nil :data nil))))
+ (pearl--graphql-request-async
+ "q" nil #'ignore (lambda (err _r _d) (setq got err))))
+ (should (equal "boom" got))))
+
+(ert-deftest test-pearl-graphql-request-async-balances-active-counter-on-error ()
+ "The active-request counter returns to its starting value after an error."
+ (let ((pearl--active-requests 0))
+ (testutil-linear-with-error "boom"
+ (pearl--graphql-request-async "q" nil #'ignore #'ignore))
+ (should (= 0 pearl--active-requests))))
+
+(ert-deftest test-pearl-graphql-request-sync-returns-data ()
+ "The synchronous wrapper returns the parsed response."
+ (testutil-linear-with-response '((data (x . 1)))
+ (should (equal '((data (x . 1))) (pearl--graphql-request "q")))))
+
+(ert-deftest test-pearl-graphql-request-sync-error-returns-nil ()
+ "The synchronous wrapper returns nil on a transport error."
+ (testutil-linear-with-error "boom"
+ (should (null (pearl--graphql-request "q")))))
+
+(provide 'test-pearl-api)
+;;; test-pearl-api.el ends here
diff --git a/tests/test-pearl-assignee-labels.el b/tests/test-pearl-assignee-labels.el
new file mode 100644
index 0000000..c70f0ca
--- /dev/null
+++ b/tests/test-pearl-assignee-labels.el
@@ -0,0 +1,114 @@
+;;; test-pearl-assignee-labels.el --- Tests for set-assignee / set-labels -*- 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:
+
+;; Tests for the two drawer-field commands that resolve names to ids:
+;; `pearl-set-assignee' and `pearl-set-labels'. They push via
+;; the generic `--update-issue-async' and update the LINEAR-ASSIGNEE / LABELS
+;; drawer. The resolver and the mutation are stubbed.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT at point-min."
+ (declare (indent 1))
+ `(with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body))
+
+;;; set-assignee
+
+(ert-deftest test-pearl-set-assignee-pushes-id-and-updates-drawer ()
+ "Setting an assignee resolves the name, pushes the id, and updates the drawer."
+ (let ((pushed nil))
+ (test-pearl--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-ASSIGNEE-ID: old\n:LINEAR-ASSIGNEE-NAME: Someone\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--resolve-team-id)
+ (lambda (_kind _name _team &optional _force) "u9"))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t)))))
+ (pearl-set-assignee "Craig")
+ (should (string= "u9" (cdr (assoc "assigneeId" pushed))))
+ (should (string= "Craig" (org-entry-get nil "LINEAR-ASSIGNEE-NAME")))
+ (should (string= "u9" (org-entry-get nil "LINEAR-ASSIGNEE-ID")))))))
+
+(ert-deftest test-pearl-set-assignee-unresolvable-errors ()
+ "An unresolvable assignee name signals a user error and pushes nothing."
+ (let ((pushed nil))
+ (test-pearl--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--resolve-team-id)
+ (lambda (&rest _) nil))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (&rest _) (setq pushed t))))
+ (should-error (pearl-set-assignee "Nobody") :type 'user-error)
+ (should-not pushed)))))
+
+(ert-deftest test-pearl-set-assignee-not-on-issue-errors ()
+ "Setting an assignee outside a Linear issue heading signals a user error."
+ (test-pearl--in-org "* Plain heading\nno id\n"
+ (should-error (pearl-set-assignee "Craig") :type 'user-error)))
+
+;;; set-labels
+
+(ert-deftest test-pearl-set-labels-pushes-ids-and-updates-drawer ()
+ "Setting labels resolves each name, pushes the id list, and updates the drawer."
+ (let ((pushed nil))
+ (test-pearl--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-LABELS: []\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--resolve-team-id)
+ (lambda (_kind name _team &optional _force)
+ (pcase name ("bug" "l1") ("p1" "l2") (_ nil))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t)))))
+ (pearl-set-labels '("bug" "p1"))
+ (should (equal '("l1" "l2") (cdr (assoc "labelIds" pushed))))
+ (should (string= "[bug, p1]" (org-entry-get nil "LINEAR-LABELS")))))))
+
+(ert-deftest test-pearl-set-labels-clear-pushes-empty ()
+ "Clearing labels (empty list) pushes an empty id list and empties the drawer."
+ (let ((pushed 'unset))
+ (test-pearl--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:LINEAR-LABELS: [bug]\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t)))))
+ (pearl-set-labels '())
+ (should (equal '() (cdr (assoc "labelIds" pushed))))
+ (should (string= "[]" (org-entry-get nil "LINEAR-LABELS")))))))
+
+(ert-deftest test-pearl-set-labels-unresolvable-errors ()
+ "An unresolvable label name signals a user error and pushes nothing."
+ (let ((pushed nil))
+ (test-pearl--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--resolve-team-id)
+ (lambda (&rest _) nil))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (&rest _) (setq pushed t))))
+ (should-error (pearl-set-labels '("ghost")) :type 'user-error)
+ (should-not pushed)))))
+
+(provide 'test-pearl-assignee-labels)
+;;; test-pearl-assignee-labels.el ends here
diff --git a/tests/test-pearl-commands.el b/tests/test-pearl-commands.el
new file mode 100644
index 0000000..77f2d46
--- /dev/null
+++ b/tests/test-pearl-commands.el
@@ -0,0 +1,153 @@
+;;; test-pearl-commands.el --- Tests for pearl user commands -*- 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:
+
+;; Tests for the interactive commands. Their input sources (completing-read,
+;; read-string) and the network/file boundaries are stubbed so the dispatch
+;; and orchestration logic runs: the list-issues result handling, project
+;; dispatch, the new-issue input-building, and the connection/setup checks.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+;;; pearl-list-issues
+
+(ert-deftest test-pearl-list-issues-ok-writes-all-issues ()
+ "On an ok result, every server-returned issue is written (no client filter)."
+ (let ((written nil))
+ (cl-letf (((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter cb &optional _order)
+ (funcall cb (pearl--make-query-result
+ 'ok :issues '(((identifier . "A")) ((identifier . "B")))))))
+ ((symbol-function 'pearl--update-org-from-issues)
+ (lambda (issues &optional _source _truncated) (setq written issues))))
+ (pearl-list-issues)
+ (should (= 2 (length written))))))
+
+(ert-deftest test-pearl-list-issues-empty-skips-write ()
+ "An empty result messages the user and does not overwrite the file."
+ (let ((wrote nil))
+ (cl-letf (((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter cb &optional _order)
+ (funcall cb (pearl--make-query-result 'empty :issues nil))))
+ ((symbol-function 'pearl--update-org-from-issues)
+ (lambda (&rest _) (setq wrote t))))
+ (pearl-list-issues)
+ (should-not wrote))))
+
+(ert-deftest test-pearl-list-issues-error-skips-write ()
+ "A failed query messages and does not write."
+ (let ((wrote nil))
+ (cl-letf (((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter cb &optional _order)
+ (funcall cb (pearl--make-query-result
+ 'request-failed :message "boom"))))
+ ((symbol-function 'pearl--update-org-from-issues)
+ (lambda (&rest _) (setq wrote t))))
+ (pearl-list-issues)
+ (should-not wrote))))
+
+(ert-deftest test-pearl-list-issues-project-id-adds-project-filter ()
+ "A project id is compiled into the issue filter passed to the fetch."
+ (let (captured-filter)
+ (cl-letf (((symbol-function 'pearl--query-issues-async)
+ (lambda (filter _cb &optional _order) (setq captured-filter filter)))
+ ((symbol-function 'pearl--update-org-from-issues) #'ignore))
+ (pearl-list-issues "proj-1")
+ (should (equal '(("id" ("eq" . "proj-1"))) (cdr (assoc "project" captured-filter)))))))
+
+;;; pearl-list-issues-by-project
+
+(ert-deftest test-pearl-list-issues-by-project-dispatches-with-project-id ()
+ "Selecting a team and project forwards the project id to list-issues."
+ (let ((pid 'unset)
+ (pearl-default-team-id nil))
+ (cl-letf (((symbol-function 'pearl-select-team) (lambda () '((id . "t1"))))
+ ((symbol-function 'pearl-select-project)
+ (lambda (_t) '((id . "p1") (name . "Platform"))))
+ ((symbol-function 'pearl-list-issues) (lambda (project-id) (setq pid project-id))))
+ (pearl-list-issues-by-project)
+ (should (string-equal "p1" pid)))))
+
+(ert-deftest test-pearl-list-issues-by-project-no-team-stops ()
+ "With no team selected, list-issues is not called."
+ (let ((called nil)
+ (pearl-default-team-id nil))
+ (cl-letf (((symbol-function 'pearl-select-team) (lambda () nil))
+ ((symbol-function 'pearl-list-issues) (lambda (&rest _) (setq called t))))
+ (pearl-list-issues-by-project)
+ (should-not called))))
+
+;;; pearl-new-issue
+
+(ert-deftest test-pearl-new-issue-success-returns-issue ()
+ "new-issue builds the input, sends it, and returns the created issue."
+ (let ((pearl-default-team-id "team-1"))
+ (cl-letf (((symbol-function 'read-string)
+ (lambda (prompt &rest _) (if (string-prefix-p "Issue title" prompt) "My Issue" "")))
+ ((symbol-function 'completing-read)
+ (lambda (prompt &rest _)
+ (cond ((string-prefix-p "State" prompt) "Todo")
+ ((string-prefix-p "Priority" prompt) "Medium")
+ ((string-prefix-p "Label category" prompt) "All")
+ (t ""))))
+ ((symbol-function 'pearl-get-states)
+ (lambda (_t) '(((id . "s1") (name . "Todo")))))
+ ((symbol-function 'pearl-get-team-members) (lambda (_t) '()))
+ ((symbol-function 'pearl-get-issue-types) (lambda (_t) '()))
+ ((symbol-function 'pearl-select-project) (lambda (_t) nil))
+ ((symbol-function 'pearl--graphql-request)
+ (lambda (_q &optional _v)
+ '((data (issueCreate (success . t)
+ (issue (id . "i1") (identifier . "ENG-1") (title . "My Issue"))))))))
+ (should (string-equal "ENG-1" (cdr (assoc 'identifier (pearl-new-issue))))))))
+
+(ert-deftest test-pearl-new-issue-no-team-stops ()
+ "new-issue stops cleanly when no team is selected."
+ (let ((pearl-default-team-id nil))
+ (cl-letf (((symbol-function 'pearl-select-team) (lambda () nil)))
+ (should (progn (pearl-new-issue) t)))))
+
+;;; pearl-test-connection / check-setup
+
+(ert-deftest test-pearl-test-connection-success-runs ()
+ "test-connection completes on a successful viewer response."
+ (testutil-linear-with-response '((data (viewer (id . "u") (name . "Me"))))
+ (should (progn (pearl-test-connection) t))))
+
+(ert-deftest test-pearl-check-setup-with-key-tests-connection ()
+ "check-setup runs the connection test when an API key is set."
+ (let ((pearl-api-key "k") (called nil))
+ (cl-letf (((symbol-function 'pearl-test-connection) (lambda () (setq called t))))
+ (pearl-check-setup)
+ (should called))))
+
+(ert-deftest test-pearl-check-setup-without-key-skips-test ()
+ "check-setup does not run the connection test without an API key."
+ (let ((pearl-api-key nil) (called nil))
+ (cl-letf (((symbol-function 'pearl-test-connection) (lambda () (setq called t))))
+ (pearl-check-setup)
+ (should-not called))))
+
+(provide 'test-pearl-commands)
+;;; test-pearl-commands.el ends here
diff --git a/tests/test-pearl-comment-editing.el b/tests/test-pearl-comment-editing.el
new file mode 100644
index 0000000..05a3a76
--- /dev/null
+++ b/tests/test-pearl-comment-editing.el
@@ -0,0 +1,240 @@
+;;; test-pearl-comment-editing.el --- Tests for editing comments -*- 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:
+
+;; Tests for editing an existing comment (spec: docs/issue-comment-editing-spec.org).
+;; Covers: the author-id retained at normalize time, the per-comment provenance
+;; drawer, the editability predicate and highlight pass, the viewer/commentUpdate
+;; network helpers (stubbed at the HTTP boundary), and the permission + conflict
+;; gates of `pearl-edit-current-comment'.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT."
+ (declare (indent 1))
+ `(let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; normalize-comment keeps the author id
+
+(ert-deftest test-pearl-normalize-comment-keeps-author-id ()
+ "A user-authored comment retains the user id for the permission check."
+ (let ((c (pearl--normalize-comment
+ '((id . "c1") (body . "hi") (createdAt . "2026-05-24T10:00:00.000Z")
+ (user (id . "u-123") (name . "Craig"))))))
+ (should (string= "u-123" (plist-get c :author-id)))
+ (should (string= "Craig" (plist-get c :author)))))
+
+(ert-deftest test-pearl-normalize-comment-bot-has-no-author-id ()
+ "A bot comment has no user, so author-id is nil (not editable)."
+ (let ((c (pearl--normalize-comment
+ '((id . "c1") (body . "hi") (createdAt . "2026-05-24T10:00:00.000Z")
+ (botActor (name . "Linear"))))))
+ (should (null (plist-get c :author-id)))))
+
+;;; format-comment writes the provenance drawer
+
+(ert-deftest test-pearl-format-comment-writes-drawer ()
+ "A rendered comment carries id, author-id, and a body hash in a drawer."
+ (let ((out (pearl--format-comment
+ '(:id "c9" :author-id "u-123" :author "Craig"
+ :created-at "2026-05-24T10:00:00.000Z" :body "Looks good"))))
+ (should (string-match-p ":LINEAR-COMMENT-ID:[ \t]+c9" out))
+ (should (string-match-p ":LINEAR-COMMENT-AUTHOR-ID:[ \t]+u-123" out))
+ (should (string-match-p (format ":LINEAR-COMMENT-SHA256:[ \t]+%s"
+ (secure-hash 'sha256 "Looks good"))
+ out))))
+
+(ert-deftest test-pearl-format-comment-bot-empty-author-id ()
+ "A comment with no author id renders an empty author-id, not an error."
+ (let ((out (pearl--format-comment
+ '(:id "c1" :author "Linear" :created-at "2026-05-24T10:00:00.000Z"
+ :body "auto"))))
+ (should (string-match-p "^:LINEAR-COMMENT-AUTHOR-ID:[ \t]*$" out))))
+
+;;; editability predicate
+
+(ert-deftest test-pearl-comment-editable-own ()
+ "A comment whose author is the viewer is editable."
+ (should (pearl--comment-editable-p "u-1" "u-1")))
+
+(ert-deftest test-pearl-comment-editable-other ()
+ "A comment by another user is not editable."
+ (should-not (pearl--comment-editable-p "u-2" "u-1")))
+
+(ert-deftest test-pearl-comment-editable-nil-author ()
+ "A comment with no author id (bot/external) is not editable."
+ (should-not (pearl--comment-editable-p nil "u-1"))
+ (should-not (pearl--comment-editable-p "" "u-1")))
+
+;;; viewer identity (cached)
+
+(ert-deftest test-pearl-viewer-async-parses-and-caches ()
+ "The viewer query returns the id/name and caches it for the next call."
+ (let ((pearl--cache-viewer nil))
+ (testutil-linear-with-response
+ '((data (viewer (id . "u-me") (name . "Craig"))))
+ (let (v)
+ (pearl--viewer-async (lambda (r) (setq v r)))
+ (should (string= "u-me" (plist-get v :id)))
+ (should (string= "u-me" (plist-get pearl--cache-viewer :id)))))
+ ;; second call uses the cache, no HTTP needed
+ (let ((v2 'untouched))
+ (pearl--viewer-async (lambda (r) (setq v2 r)))
+ (should (string= "u-me" (plist-get v2 :id))))))
+
+;;; commentUpdate write path
+
+(ert-deftest test-pearl-update-comment-success ()
+ "A successful commentUpdate reports success."
+ (testutil-linear-with-response
+ '((data (commentUpdate (success . t) (comment (id . "c1") (body . "edited")))))
+ (let (result)
+ (pearl--update-comment-async "c1" "edited" (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success))))))
+
+(ert-deftest test-pearl-update-comment-soft-fail ()
+ "A non-success commentUpdate reports failure rather than erroring."
+ (testutil-linear-with-response
+ '((data (commentUpdate (success . :json-false) (comment . nil))))
+ (let (result)
+ (pearl--update-comment-async "c1" "x" (lambda (r) (setq result r)))
+ (should-not (plist-get result :success)))))
+
+;;; edit command — permission + conflict gates
+
+(defconst test-pearl--comment-buffer
+ (concat "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n"
+ "**** Comments\n"
+ "***** Craig — 2026-05-24T09:00:00.000Z\n"
+ ":PROPERTIES:\n"
+ ":LINEAR-COMMENT-ID: c1\n"
+ ":LINEAR-COMMENT-AUTHOR-ID: %s\n"
+ ":LINEAR-COMMENT-SHA256: %s\n"
+ ":END:\n"
+ "%s\n")
+ "Template: author-id, stored-sha, body.")
+
+(defun test-pearl--comment-doc (author-id stored-body current-body)
+ "Build a comment buffer: AUTHOR-ID, drawer hash of STORED-BODY, CURRENT-BODY shown."
+ (format test-pearl--comment-buffer
+ author-id (secure-hash 'sha256 stored-body) current-body))
+
+(ert-deftest test-pearl-edit-comment-not-on-comment-errors ()
+ "Running the edit command outside a comment subtree signals a user error."
+ (test-pearl--in-org "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n"
+ (should-error (pearl-edit-current-comment) :type 'user-error)))
+
+(ert-deftest test-pearl-edit-comment-refuses-others ()
+ "Editing another user's comment refuses and never calls commentUpdate."
+ (test-pearl--in-org (test-pearl--comment-doc "u-other" "old" "new text")
+ (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))
+ (updated nil) (fetched nil))
+ (cl-letf (((symbol-function 'pearl--update-comment-async)
+ (lambda (&rest _) (setq updated t)))
+ ((symbol-function 'pearl--fetch-comment-body-async)
+ (lambda (&rest _) (setq fetched t))))
+ (re-search-forward "new text")
+ (pearl-edit-current-comment)
+ (should-not updated)
+ (should-not fetched)))))
+
+(ert-deftest test-pearl-edit-comment-noop-when-unchanged ()
+ "An unedited comment pushes nothing."
+ (test-pearl--in-org (test-pearl--comment-doc "u-me" "same" "same")
+ (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))
+ (updated nil))
+ (cl-letf (((symbol-function 'pearl--update-comment-async)
+ (lambda (&rest _) (setq updated t))))
+ (re-search-forward "same")
+ (pearl-edit-current-comment)
+ (should-not updated)))))
+
+(ert-deftest test-pearl-edit-comment-pushes-own-edit ()
+ "Editing your own comment against an unchanged remote pushes and advances the hash."
+ (test-pearl--in-org (test-pearl--comment-doc "u-me" "old body" "new body")
+ (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))
+ (pushed-body nil))
+ (cl-letf (((symbol-function 'pearl--fetch-comment-body-async)
+ ;; remote is unchanged since fetch (matches the stored hash)
+ (lambda (_id cb) (funcall cb "old body")))
+ ((symbol-function 'pearl--update-comment-async)
+ (lambda (_id body cb) (setq pushed-body body)
+ (funcall cb '(:success t)))))
+ (re-search-forward "new body")
+ (pearl-edit-current-comment)
+ (should (string= "new body" pushed-body))
+ ;; the stored hash advanced to the pushed body
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\*\\*\\*\\* Craig")
+ (should (string= (secure-hash 'sha256 "new body")
+ (org-entry-get nil "LINEAR-COMMENT-SHA256")))))))
+
+(ert-deftest test-pearl-edit-comment-refuses-conflict ()
+ "When the remote changed since the fetch, the edit is refused."
+ (test-pearl--in-org (test-pearl--comment-doc "u-me" "old body" "new body")
+ (let ((pearl--cache-viewer '(:id "u-me" :name "Me"))
+ (updated nil))
+ (cl-letf (((symbol-function 'pearl--fetch-comment-body-async)
+ ;; remote drifted from the stored hash -> conflict
+ (lambda (_id cb) (funcall cb "remote changed body")))
+ ((symbol-function 'pearl--update-comment-async)
+ (lambda (&rest _) (setq updated t)))
+ ;; On conflict the command now prompts; cancel keeps the old
+ ;; refuse behavior (no commentUpdate call).
+ ((symbol-function 'pearl--read-conflict-resolution)
+ (lambda (_label) 'cancel)))
+ (re-search-forward "new body")
+ (pearl-edit-current-comment)
+ (should-not updated)))))
+
+;;; editability highlighting
+
+(ert-deftest test-pearl-highlight-comments-colors-by-editability ()
+ "Own comments get the editable face; others get the read-only face."
+ (test-pearl--in-org
+ (concat "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n"
+ "**** Comments\n"
+ "***** Me — 2026-05-24T09:00:00.000Z\n"
+ ":PROPERTIES:\n:LINEAR-COMMENT-ID: c1\n:LINEAR-COMMENT-AUTHOR-ID: u-me\n:END:\nmine\n"
+ "***** Them — 2026-05-24T10:00:00.000Z\n"
+ ":PROPERTIES:\n:LINEAR-COMMENT-ID: c2\n:LINEAR-COMMENT-AUTHOR-ID: u-other\n:END:\ntheirs\n")
+ (pearl--apply-comment-highlights "u-me")
+ (cl-flet ((face-on (pat)
+ (goto-char (point-min))
+ (re-search-forward pat)
+ (goto-char (line-beginning-position))
+ (let ((ov (cl-find-if (lambda (o) (overlay-get o 'pearl-comment))
+ (overlays-at (point)))))
+ (and ov (overlay-get ov 'face)))))
+ (should (eq 'pearl-editable-comment (face-on "^\\*\\*\\*\\*\\* Me")))
+ (should (eq 'pearl-readonly-comment (face-on "^\\*\\*\\*\\*\\* Them"))))))
+
+(provide 'test-pearl-comment-editing)
+;;; test-pearl-comment-editing.el ends here
diff --git a/tests/test-pearl-comments.el b/tests/test-pearl-comments.el
new file mode 100644
index 0000000..7fa4482
--- /dev/null
+++ b/tests/test-pearl-comments.el
@@ -0,0 +1,170 @@
+;;; test-pearl-comments.el --- Tests for issue comments -*- 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:
+
+;; Tests for the comment thread: rendering a normalized comment and the
+;; oldest-first Comments subtree, including comments in the issue render, the
+;; commentCreate helper (stubbed at the HTTP boundary), the in-place append
+;; under the Comments subtree (creating it when absent), and the
+;; `pearl-add-comment' command.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; --format-comment / --format-comments
+
+(ert-deftest test-pearl-format-comment-renders-author-time-body ()
+ "A comment renders as a level-4 heading with author and timestamp, body below."
+ (let ((out (pearl--format-comment
+ '(:id "c1" :author "Craig" :created-at "2026-05-23T10:00:00.000Z"
+ :body "Looks **good** to me"))))
+ (should (string-match-p "^\\*\\*\\*\\* Craig — 2026-05-23T10:00:00.000Z$" out))
+ ;; body runs through the md->org tier
+ (should (string-match-p "Looks \\*good\\* to me" out))))
+
+(ert-deftest test-pearl-format-comment-null-author ()
+ "A comment with no resolved author renders a placeholder, not an error."
+ (let ((out (pearl--format-comment
+ '(:id "c1" :author nil :created-at "2026-05-23T10:00:00.000Z" :body "hi"))))
+ (should (string-match-p "^\\*\\*\\*\\* (unknown) — 2026-05-23T10:00:00.000Z$" out))))
+
+(ert-deftest test-pearl-format-comments-empty-is-blank ()
+ "No comments renders nothing (no empty Comments subtree)."
+ (should (string= "" (pearl--format-comments nil))))
+
+(ert-deftest test-pearl-format-comments-oldest-first ()
+ "Comments render under a Comments heading, oldest first regardless of input order."
+ (let ((out (pearl--format-comments
+ '((:id "c2" :author "B" :created-at "2026-05-23T12:00:00.000Z" :body "second")
+ (:id "c1" :author "A" :created-at "2026-05-23T09:00:00.000Z" :body "first")))))
+ (should (string-match-p "^\\*\\*\\* Comments$" out))
+ (should (< (string-match "first" out) (string-match "second" out)))))
+
+;;; comments in the issue render
+
+(ert-deftest test-pearl-format-issue-includes-comments ()
+ "A normalized issue carrying comments renders the Comments subtree after the body."
+ (test-pearl--in-org ""
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "ENG-1" :title "Title" :priority 2
+ :state (:name "Todo") :description "Body text."
+ :comments ((:id "c1" :author "A" :created-at "2026-05-23T09:00:00.000Z"
+ :body "a comment"))))))
+ (should (string-match-p "^\\*\\*\\* Comments$" out))
+ (should (< (string-match "Body text." out) (string-match "a comment" out))))))
+
+;;; --create-comment-async
+
+(ert-deftest test-pearl-create-comment-parses-payload ()
+ "A successful commentCreate yields the normalized comment."
+ (testutil-linear-with-response
+ '((data (commentCreate
+ (success . t)
+ (comment (id . "c9") (body . "new one")
+ (createdAt . "2026-05-23T13:00:00.000Z")
+ (user (name . "Craig"))))))
+ (let (result)
+ (pearl--create-comment-async "issue-a" "new one" (lambda (r) (setq result r)))
+ (should (string= "c9" (plist-get result :id)))
+ (should (string= "Craig" (plist-get result :author))))))
+
+(ert-deftest test-pearl-create-comment-soft-fail ()
+ "A non-success commentCreate yields nil rather than erroring."
+ (testutil-linear-with-response
+ '((data (commentCreate (success . :json-false) (comment . nil))))
+ (let ((called nil) (result 'untouched))
+ (pearl--create-comment-async "issue-a" "x" (lambda (r) (setq called t result r)))
+ (should called)
+ (should (null result)))))
+
+;;; --append-comment-to-issue
+
+(ert-deftest test-pearl-append-comment-creates-subtree ()
+ "Appending to an issue with no Comments subtree creates one."
+ (test-pearl--in-org
+ "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n"
+ (pearl--append-comment-to-issue
+ '(:id "c1" :author "A" :created-at "2026-05-23T09:00:00.000Z" :body "first comment"))
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\*\\* Comments$" nil t))
+ (should (re-search-forward "first comment" nil t))))
+
+(ert-deftest test-pearl-append-comment-after-existing ()
+ "A new comment appends after an existing one under the Comments subtree."
+ (test-pearl--in-org
+ "** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n*** Comments\n**** A — 2026-05-23T09:00:00.000Z\nfirst\n"
+ (pearl--append-comment-to-issue
+ '(:id "c2" :author "B" :created-at "2026-05-23T12:00:00.000Z" :body "second"))
+ (goto-char (point-min))
+ ;; only one Comments heading, and the new comment follows the first
+ (should (re-search-forward "^\\*\\*\\* Comments$" nil t))
+ (should-not (re-search-forward "^\\*\\*\\* Comments$" nil t))
+ (goto-char (point-min))
+ (should (< (progn (re-search-forward "first") (point))
+ (progn (re-search-forward "second") (point))))))
+
+;;; pearl-add-comment
+
+(ert-deftest test-pearl-add-comment-appends-returned-comment ()
+ "The command creates a comment and inserts the returned one in the buffer."
+ (test-pearl--in-org
+ "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n"
+ (cl-letf (((symbol-function 'pearl--create-comment-async)
+ (lambda (_id body cb)
+ (funcall cb (list :id "c1" :author "Craig"
+ :created-at "2026-05-23T14:00:00.000Z" :body body)))))
+ (re-search-forward "Body.")
+ (pearl-add-comment "my new comment")
+ (goto-char (point-min))
+ (should (re-search-forward "^\\*\\*\\* Comments$" nil t))
+ (should (re-search-forward "my new comment" nil t)))))
+
+(ert-deftest test-pearl-add-comment-reports-failure ()
+ "A failed create does not insert a Comments subtree."
+ (test-pearl--in-org
+ "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nBody.\n"
+ (cl-letf (((symbol-function 'pearl--create-comment-async)
+ (lambda (_id _body cb) (funcall cb nil))))
+ (pearl-add-comment "x")
+ (goto-char (point-min))
+ (should-not (re-search-forward "^\\*\\*\\* Comments$" nil t)))))
+
+(ert-deftest test-pearl-add-comment-not-on-issue-errors ()
+ "Adding a comment outside a Linear issue heading signals a user error."
+ (test-pearl--in-org "* Plain heading\nno id\n"
+ (should-error (pearl-add-comment "x") :type 'user-error)))
+
+(provide 'test-pearl-comments)
+;;; test-pearl-comments.el ends here
diff --git a/tests/test-pearl-config.el b/tests/test-pearl-config.el
new file mode 100644
index 0000000..e83414c
--- /dev/null
+++ b/tests/test-pearl-config.el
@@ -0,0 +1,76 @@
+;;; test-pearl-config.el --- Tests for pearl config helpers -*- 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 small config / state helpers: `pearl--headers',
+;; `pearl-toggle-debug', and `pearl-load-api-key-from-env'.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; pearl--headers
+
+(ert-deftest test-pearl-headers-no-key-errors ()
+ "Building headers without an API key signals an error."
+ (let ((pearl-api-key nil))
+ (should-error (pearl--headers))))
+
+(ert-deftest test-pearl-headers-with-key-sets-authorization ()
+ "With a key set, the Authorization header carries the raw key (no Bearer)."
+ (let ((pearl-api-key "lin_api_abc123"))
+ (let ((headers (pearl--headers)))
+ (should (string-equal "lin_api_abc123" (cdr (assoc "Authorization" headers))))
+ (should (string-equal "application/json" (cdr (assoc "Content-Type" headers)))))))
+
+;;; pearl-toggle-debug
+
+(ert-deftest test-pearl-toggle-debug-flips-from-nil ()
+ "Toggling from nil enables debug."
+ (let ((pearl-debug nil))
+ (pearl-toggle-debug)
+ (should (eq t pearl-debug))))
+
+(ert-deftest test-pearl-toggle-debug-flips-from-t ()
+ "Toggling from t disables debug."
+ (let ((pearl-debug t))
+ (pearl-toggle-debug)
+ (should (null pearl-debug))))
+
+;;; pearl-load-api-key-from-env
+
+(ert-deftest test-pearl-load-api-key-from-env-present ()
+ "When LINEAR_API_KEY is set, the key is loaded into the variable."
+ (let ((process-environment (cons "LINEAR_API_KEY=env-key-xyz" process-environment))
+ (pearl-api-key nil))
+ (pearl-load-api-key-from-env)
+ (should (string-equal "env-key-xyz" pearl-api-key))))
+
+(ert-deftest test-pearl-load-api-key-from-env-absent-leaves-key ()
+ "When LINEAR_API_KEY is unset, the key variable is left unchanged."
+ (let ((process-environment
+ (seq-remove (lambda (e) (string-prefix-p "LINEAR_API_KEY=" e))
+ process-environment))
+ (pearl-api-key nil))
+ (pearl-load-api-key-from-env)
+ (should (null pearl-api-key))))
+
+(provide 'test-pearl-config)
+;;; test-pearl-config.el ends here
diff --git a/tests/test-pearl-conflict.el b/tests/test-pearl-conflict.el
new file mode 100644
index 0000000..03d8f31
--- /dev/null
+++ b/tests/test-pearl-conflict.el
@@ -0,0 +1,282 @@
+;;; test-pearl-conflict.el --- Tests for interactive conflict resolution -*- 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:
+
+;; Tests for the conflict-resolution foundation: the no-data-loss stash
+;; (`pearl--stash-conflict-text'), the smerge conflict-string builder
+;; (`pearl--conflict-smerge-string'), and the resolution prompt
+;; (`pearl--read-conflict-resolution', cancel-by-default).
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl-conflict--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+(defun test-pearl-conflict--marker ()
+ "Return a marker at the first issue heading in the current buffer."
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\*\\* ")
+ (beginning-of-line)
+ (point-marker))
+
+;;; --stash-conflict-text
+
+(ert-deftest test-pearl-stash-conflict-text-to-kill-ring-and-buffer ()
+ "Stashing puts the text on the kill ring and into the backup buffer."
+ (let ((kill-ring nil))
+ (when (get-buffer "*pearl-conflict-backup*")
+ (kill-buffer "*pearl-conflict-backup*"))
+ (pearl--stash-conflict-text "ENG-1 description" "My local edit.")
+ (should (string= "My local edit." (current-kill 0)))
+ (with-current-buffer "*pearl-conflict-backup*"
+ (let ((s (buffer-string)))
+ (should (string-match-p "ENG-1 description" s))
+ (should (string-match-p "My local edit\\." s))))))
+
+(ert-deftest test-pearl-stash-conflict-text-appends-not-overwrites ()
+ "A second stash appends below the first, preserving earlier backups."
+ (let ((kill-ring nil))
+ (when (get-buffer "*pearl-conflict-backup*")
+ (kill-buffer "*pearl-conflict-backup*"))
+ (pearl--stash-conflict-text "ENG-1 description" "First edit.")
+ (pearl--stash-conflict-text "ENG-2 title" "Second edit.")
+ (with-current-buffer "*pearl-conflict-backup*"
+ (let ((s (buffer-string)))
+ (should (string-match-p "First edit\\." s))
+ (should (string-match-p "Second edit\\." s))))))
+
+(ert-deftest test-pearl-stash-conflict-text-empty-is-noop ()
+ "Stashing empty text touches neither the kill ring nor the backup buffer."
+ (let ((kill-ring nil))
+ (when (get-buffer "*pearl-conflict-backup*")
+ (kill-buffer "*pearl-conflict-backup*"))
+ (pearl--stash-conflict-text "ENG-1 description" "")
+ (should (null kill-ring))
+ (should-not (get-buffer "*pearl-conflict-backup*"))))
+
+;;; --conflict-smerge-string
+
+(ert-deftest test-pearl-conflict-smerge-string-has-markers-in-order ()
+ "The smerge string carries the three markers with local before remote."
+ (let ((s (pearl--conflict-smerge-string "LOCAL TEXT" "REMOTE TEXT")))
+ (should (string-match-p "^<<<<<<<" s))
+ (should (string-match-p "^=======" s))
+ (should (string-match-p "^>>>>>>>" s))
+ (let ((lt (string-match "LOCAL TEXT" s))
+ (sep (string-match "^=======" s))
+ (rt (string-match "REMOTE TEXT" s)))
+ (should (< lt sep))
+ (should (< sep rt)))))
+
+(ert-deftest test-pearl-conflict-smerge-string-newline-terminates-sections ()
+ "Sections whose text lacks a trailing newline still get one before a marker."
+ (let ((s (pearl--conflict-smerge-string "no-newline-local" "no-newline-remote")))
+ ;; The separator and closing markers must each start their own line.
+ (should (string-match-p "no-newline-local\n=======" s))
+ (should (string-match-p "no-newline-remote\n>>>>>>>" s))))
+
+;;; --read-conflict-resolution
+
+(ert-deftest test-pearl-read-conflict-resolution-maps-choices ()
+ "Each prompt label maps to its resolution symbol."
+ (dolist (case '(("use local" . use-local)
+ ("use remote" . use-remote)
+ ("rewrite" . rewrite)
+ ("cancel" . cancel)))
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_prompt collection &rest _)
+ ;; Return the first offered label containing the case keyword.
+ (seq-find (lambda (c) (string-match-p (car case) c)) collection))))
+ (should (eq (cdr case) (pearl--read-conflict-resolution "ENG-1 description"))))))
+
+(ert-deftest test-pearl-read-conflict-resolution-defaults-to-cancel ()
+ "Selecting the default (a bare RET) resolves to `cancel'."
+ (cl-letf (((symbol-function 'completing-read)
+ (lambda (_prompt _collection &rest args)
+ ;; Emulate RET-on-default: completing-read returns the DEF arg.
+ ;; args = (predicate require-match initial-input hist def ...),
+ ;; so DEF is the 5th element, index 4.
+ (nth 4 args))))
+ (should (eq 'cancel (pearl--read-conflict-resolution "ENG-1 description")))))
+
+;;; --set-entry-body-at-point
+
+(ert-deftest test-pearl-set-entry-body-replaces-body-keeps-drawer ()
+ "Setting the body replaces the text after the drawer and preserves the drawer."
+ (test-pearl-conflict--in-org
+ "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: x\n:END:\nOld body.\n"
+ (re-search-forward "Title")
+ (pearl--set-entry-body-at-point "New body line.")
+ (goto-char (point-min))
+ (should (re-search-forward "New body line\\." nil t))
+ (goto-char (point-min))
+ (should-not (re-search-forward "Old body\\." nil t))
+ (should (string= "a" (org-entry-get nil "LINEAR-ID")))))
+
+(ert-deftest test-pearl-set-entry-body-stops-before-child-heading ()
+ "Setting the body does not disturb a child Comments subtree."
+ (test-pearl-conflict--in-org
+ (concat "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nOld body.\n"
+ "**** Comments\n***** Me — t\nmine\n")
+ (re-search-forward "Title")
+ (pearl--set-entry-body-at-point "New body.")
+ (goto-char (point-min))
+ (should (re-search-forward "New body\\." nil t))
+ (should (re-search-forward "Comments" nil t))
+ (should (re-search-forward "mine" nil t))))
+
+;;; --resolve-conflict
+
+(ert-deftest test-pearl-resolve-conflict-cancel-does-nothing ()
+ "Cancel applies nothing, pushes nothing, and leaves the provenance hash."
+ (test-pearl-conflict--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
+ (let ((applied nil) (pushed nil) (marker (test-pearl-conflict--marker)))
+ (cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'cancel)))
+ (pearl--resolve-conflict
+ "ENG-1 description" "local" "remote" marker "LINEAR-DESC-SHA256"
+ (lambda (_md) (setq applied t))
+ (lambda (_md cb) (setq pushed t) (funcall cb t)))
+ (should-not applied)
+ (should-not pushed)
+ (should (string= "H0" (org-entry-get marker "LINEAR-DESC-SHA256")))))))
+
+(ert-deftest test-pearl-resolve-conflict-use-local-pushes-and-advances ()
+ "Use-local pushes the local text and advances the hash to it on success."
+ (test-pearl-conflict--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
+ (let ((pushed-md nil) (applied nil) (marker (test-pearl-conflict--marker)))
+ (cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'use-local)))
+ (pearl--resolve-conflict
+ "ENG-1 description" "local text" "remote text" marker "LINEAR-DESC-SHA256"
+ (lambda (_md) (setq applied t))
+ (lambda (md cb) (setq pushed-md md) (funcall cb t)))
+ (should (string= "local text" pushed-md))
+ (should-not applied)
+ (should (string= (secure-hash 'sha256 "local text")
+ (org-entry-get marker "LINEAR-DESC-SHA256")))))))
+
+(ert-deftest test-pearl-resolve-conflict-use-remote-stashes-applies-no-push ()
+ "Use-remote stashes local, writes remote, advances the hash, and never pushes."
+ (let ((kill-ring nil))
+ (test-pearl-conflict--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
+ (let ((applied-md nil) (pushed nil) (marker (test-pearl-conflict--marker)))
+ (cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'use-remote)))
+ (pearl--resolve-conflict
+ "ENG-1 description" "local text" "remote text" marker "LINEAR-DESC-SHA256"
+ (lambda (md) (setq applied-md md))
+ (lambda (_md cb) (setq pushed t) (funcall cb t)))
+ (should (string= "remote text" applied-md))
+ (should-not pushed)
+ (should (string= (secure-hash 'sha256 "remote text")
+ (org-entry-get marker "LINEAR-DESC-SHA256")))
+ ;; the local edit was stashed, not lost
+ (should (string= "local text" (current-kill 0))))))))
+
+(ert-deftest test-pearl-resolve-conflict-rewrite-applies-and-pushes ()
+ "Rewrite stashes local, then on the smerge finish applies and pushes the merge."
+ (let ((kill-ring nil))
+ (test-pearl-conflict--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: H0\n:END:\nbody\n"
+ (let ((applied nil) (pushed nil) (marker (test-pearl-conflict--marker)))
+ (cl-letf (((symbol-function 'pearl--read-conflict-resolution) (lambda (_) 'rewrite))
+ ;; Emulate the user resolving the buffer and committing.
+ ((symbol-function 'pearl--resolve-conflict-in-smerge)
+ (lambda (_label _local _remote on-finish)
+ (funcall on-finish "merged text"))))
+ (pearl--resolve-conflict
+ "ENG-1 description" "local text" "remote text" marker "LINEAR-DESC-SHA256"
+ (lambda (md) (setq applied md))
+ (lambda (md cb) (setq pushed md) (funcall cb t)))
+ (should (string= "local text" (current-kill 0)))
+ (should (string= "merged text" applied))
+ (should (string= "merged text" pushed))
+ (should (string= (secure-hash 'sha256 "merged text")
+ (org-entry-get marker "LINEAR-DESC-SHA256"))))))))
+
+;;; --conflict-has-markers-p
+
+(ert-deftest test-pearl-conflict-has-markers-p ()
+ "Unresolved marker text reports markers; resolved text does not."
+ (should (pearl--conflict-has-markers-p
+ (pearl--conflict-smerge-string "mine" "theirs")))
+ (should-not (pearl--conflict-has-markers-p "just the merged line\n")))
+
+;;; --resolve-conflict-in-smerge (buffer setup)
+
+(ert-deftest test-pearl-resolve-conflict-in-smerge-sets-up-buffer ()
+ "Opening the smerge buffer fills it with both sides and arms the callback."
+ (let ((buf-name "*pearl-merge: ENG-1 description*"))
+ (when (get-buffer buf-name) (kill-buffer buf-name))
+ (cl-letf (((symbol-function 'pop-to-buffer) #'ignore))
+ (pearl--resolve-conflict-in-smerge
+ "ENG-1 description" "my local" "the remote" (lambda (_) nil)))
+ (let ((buf (get-buffer buf-name)))
+ (should buf)
+ (with-current-buffer buf
+ (should (string-match-p "my local" (buffer-string)))
+ (should (string-match-p "the remote" (buffer-string)))
+ (should (bound-and-true-p smerge-mode))
+ (should (functionp pearl--conflict-on-finish)))
+ (kill-buffer buf))))
+
+;;; --conflict-commit / --conflict-abort
+
+(ert-deftest test-pearl-conflict-commit-refuses-with-markers ()
+ "Committing with markers still present errors and never calls the callback."
+ (let ((called nil) (buf-name "*pearl-merge: ENG-1 description*"))
+ (when (get-buffer buf-name) (kill-buffer buf-name))
+ (cl-letf (((symbol-function 'pop-to-buffer) #'ignore))
+ (pearl--resolve-conflict-in-smerge
+ "ENG-1 description" "mine" "theirs" (lambda (_) (setq called t))))
+ (with-current-buffer buf-name
+ (should-error (pearl--conflict-commit) :type 'user-error))
+ (should-not called)
+ (when (get-buffer buf-name) (kill-buffer buf-name))))
+
+(ert-deftest test-pearl-conflict-commit-resolved-calls-callback-and-kills ()
+ "With markers resolved, commit hands the text to the callback and kills the buffer."
+ (let ((got nil) (buf-name "*pearl-merge: ENG-1 description*"))
+ (when (get-buffer buf-name) (kill-buffer buf-name))
+ (cl-letf (((symbol-function 'pop-to-buffer) #'ignore))
+ (pearl--resolve-conflict-in-smerge
+ "ENG-1 description" "mine" "theirs" (lambda (txt) (setq got txt))))
+ (with-current-buffer buf-name
+ (erase-buffer)
+ (insert "the reconciled text\n")
+ (pearl--conflict-commit))
+ (should (string= "the reconciled text\n" got))
+ (should-not (get-buffer buf-name))))
+
+(provide 'test-pearl-conflict)
+;;; test-pearl-conflict.el ends here
diff --git a/tests/test-pearl-convert.el b/tests/test-pearl-convert.el
new file mode 100644
index 0000000..4125e22
--- /dev/null
+++ b/tests/test-pearl-convert.el
@@ -0,0 +1,172 @@
+;;; test-pearl-convert.el --- Tests for the markdown->org conversion -*- 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:
+
+;; Tests for `pearl--md-to-org' and `pearl--md-line-to-org' --
+;; the pure-elisp markdown->org conversion tier. Cover each supported
+;; construct (links, inline code, bold, underscore italics, headings, bullets,
+;; fenced code), the heading-safety guard, and literal pass-through.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; inline conversion
+
+(ert-deftest test-pearl-convert-link ()
+ "A markdown link becomes an org link with url and label swapped."
+ (should (string= "see [[https://x.y][the docs]] now"
+ (pearl--md-line-to-org "see [the docs](https://x.y) now"))))
+
+(ert-deftest test-pearl-convert-inline-code ()
+ "Inline code backticks become org verbatim tildes."
+ (should (string= "call ~foo()~ here"
+ (pearl--md-line-to-org "call `foo()` here"))))
+
+(ert-deftest test-pearl-convert-bold ()
+ "Markdown bold becomes org bold."
+ (should (string= "a *strong* word" (pearl--md-line-to-org "a **strong** word"))))
+
+(ert-deftest test-pearl-convert-italic-underscore ()
+ "Underscore italics become org italics, but identifiers are left alone."
+ (should (string= "an /emphatic/ point"
+ (pearl--md-line-to-org "an _emphatic_ point")))
+ (should (string= "the foo_bar_baz name"
+ (pearl--md-line-to-org "the foo_bar_baz name"))))
+
+;;; line / block conversion
+
+(ert-deftest test-pearl-convert-heading-to-bold ()
+ "A markdown heading becomes a bold line, never an org heading."
+ (let ((out (pearl--md-to-org "## Big Heading")))
+ (should (string= "*Big Heading*" out))
+ (should-not (string-match-p "^\\*+ " out))))
+
+(ert-deftest test-pearl-convert-bullets ()
+ "Markdown `*' and `+' bullets become org `-' bullets."
+ (should (string= "- one\n- two"
+ (pearl--md-to-org "* one\n+ two"))))
+
+(ert-deftest test-pearl-convert-fenced-code ()
+ "A fenced code block becomes a src block, verbatim inside."
+ (should (string= "#+begin_src elisp\n(+ 1 2)\n#+end_src"
+ (pearl--md-to-org "```elisp\n(+ 1 2)\n```"))))
+
+(ert-deftest test-pearl-convert-code-block-is-verbatim ()
+ "Inline markup inside a fenced block is not converted."
+ (let ((out (pearl--md-to-org "```\n**not bold** here\n```")))
+ (should (string-match-p "\\*\\*not bold\\*\\*" out))))
+
+(ert-deftest test-pearl-convert-guards-heading-line ()
+ "A non-bullet line that Org would read as a heading is space-guarded."
+ (let ((out (pearl--md-to-org "** looks like a heading")))
+ (should-not (string-match-p "^\\*+ " out))
+ (should (string-prefix-p " " out))))
+
+(ert-deftest test-pearl-convert-passes-through-plain-and-tables ()
+ "Plain text and unsupported constructs (tables) pass through unchanged."
+ (should (string= "just some text" (pearl--md-to-org "just some text")))
+ (should (string= "| a | b |\n|---|---|"
+ (pearl--md-to-org "| a | b |\n|---|---|"))))
+
+(ert-deftest test-pearl-convert-empty ()
+ "An empty or nil description converts to the empty string."
+ (should (string= "" (pearl--md-to-org "")))
+ (should (string= "" (pearl--md-to-org nil))))
+
+;;; org -> markdown (the push direction)
+
+(ert-deftest test-pearl-org-to-md-link ()
+ "An org link becomes a markdown link with label and url swapped back."
+ (should (string= "see [the docs](https://x.y) now"
+ (pearl--org-line-to-md "see [[https://x.y][the docs]] now"))))
+
+(ert-deftest test-pearl-org-to-md-bare-link ()
+ "An org link with no description becomes the bare url."
+ (should (string= "visit https://x.y"
+ (pearl--org-line-to-md "visit [[https://x.y]]"))))
+
+(ert-deftest test-pearl-org-to-md-inline-code ()
+ "Org verbatim tildes become markdown backticks."
+ (should (string= "call `foo()` here"
+ (pearl--org-line-to-md "call ~foo()~ here"))))
+
+(ert-deftest test-pearl-org-to-md-bold ()
+ "Org bold becomes markdown bold."
+ (should (string= "a **strong** word" (pearl--org-line-to-md "a *strong* word"))))
+
+(ert-deftest test-pearl-org-to-md-italic ()
+ "Org italics become underscore italics, but paths are left alone."
+ (should (string= "an _emphatic_ point"
+ (pearl--org-line-to-md "an /emphatic/ point")))
+ (should (string= "the /usr/local/bin path"
+ (pearl--org-line-to-md "the /usr/local/bin path"))))
+
+(ert-deftest test-pearl-org-to-md-fenced-code ()
+ "An org src block becomes a fenced code block, language preserved."
+ (should (string= "```elisp\n(+ 1 2)\n```"
+ (pearl--org-to-md "#+begin_src elisp\n(+ 1 2)\n#+end_src"))))
+
+(ert-deftest test-pearl-org-to-md-code-block-is-verbatim ()
+ "Org markup inside a src block is not converted back."
+ (let ((out (pearl--org-to-md "#+begin_src\n*not bold* here\n#+end_src")))
+ (should (string-match-p "\\*not bold\\* here" out))))
+
+(ert-deftest test-pearl-org-to-md-quote-block ()
+ "An org quote block becomes markdown blockquote lines."
+ (should (string= "> a quote\n> second line"
+ (pearl--org-to-md
+ "#+begin_quote\na quote\nsecond line\n#+end_quote"))))
+
+(ert-deftest test-pearl-org-to-md-checkbox-case ()
+ "Org uppercase checkbox marks normalize to markdown lowercase."
+ (should (string= "- [ ] todo\n- [x] done"
+ (pearl--org-to-md "- [ ] todo\n- [X] done"))))
+
+(ert-deftest test-pearl-org-to-md-empty ()
+ "An empty or nil body converts to the empty string."
+ (should (string= "" (pearl--org-to-md "")))
+ (should (string= "" (pearl--org-to-md nil))))
+
+(ert-deftest test-pearl-org-to-md-passes-through-tables ()
+ "Tables and unsupported constructs pass through unchanged."
+ (should (string= "| a | b |\n|---|---|"
+ (pearl--org-to-md "| a | b |\n|---|---|"))))
+
+;;; round-trip: org-to-md inverts md-to-org for the supported subset
+
+(ert-deftest test-pearl-convert-roundtrip-identity ()
+ "For the cleanly-supported constructs, org->md(md->org(x)) == x.
+Markdown headings and single-asterisk italics are intentionally lossy (see
+the conversion-tier docstring) and are excluded here."
+ (dolist (md '("a **strong** word"
+ "call `foo()` here"
+ "an _emphatic_ point"
+ "see [the docs](https://x.y) now"
+ "- one\n- two\n- three"
+ "1. first\n2. second"
+ "- [ ] todo\n- [x] done"
+ "```elisp\n(+ 1 2)\n```"
+ "just some plain prose"
+ "| a | b |\n|---|---|"))
+ (should (string= md (pearl--org-to-md (pearl--md-to-org md))))))
+
+(provide 'test-pearl-convert)
+;;; test-pearl-convert.el ends here
diff --git a/tests/test-pearl-delete.el b/tests/test-pearl-delete.el
new file mode 100644
index 0000000..bd412c7
--- /dev/null
+++ b/tests/test-pearl-delete.el
@@ -0,0 +1,97 @@
+;;; test-pearl-delete.el --- Tests for deleting an issue -*- 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:
+
+;; Tests for `pearl--delete-issue-async' (the issueDelete mutation,
+;; stubbed) and `pearl-delete-current-issue', which confirms, deletes,
+;; and removes the issue subtree from the buffer on success.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT at point-min."
+ (declare (indent 1))
+ `(with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body))
+
+;;; --delete-issue-async
+
+(ert-deftest test-pearl-delete-issue-async-success ()
+ "A successful issueDelete reports success."
+ (testutil-linear-with-response
+ '((data (issueDelete (success . t))))
+ (let (result)
+ (pearl--delete-issue-async "id-1" (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success))))))
+
+(ert-deftest test-pearl-delete-issue-async-soft-fail ()
+ "A non-success issueDelete reports failure rather than erroring."
+ (testutil-linear-with-response
+ '((data (issueDelete (success . :json-false))))
+ (let ((called nil) result)
+ (pearl--delete-issue-async "id-1" (lambda (r) (setq called t result r)))
+ (should called)
+ (should-not (plist-get result :success)))))
+
+;;; delete-current-issue
+
+(ert-deftest test-pearl-delete-current-issue-confirmed-removes-subtree ()
+ "Confirming the delete removes the issue subtree from the buffer."
+ (test-pearl--in-org
+ "* Header\n\n*** TODO ENG-1 Doomed\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-IDENTIFIER: ENG-1\n:END:\nbody\n*** TODO ENG-2 Survivor\n:PROPERTIES:\n:LINEAR-ID: b\n:END:\n"
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t))
+ ((symbol-function 'pearl--delete-issue-async)
+ (lambda (_id cb) (funcall cb '(:success t)))))
+ (re-search-forward "Doomed")
+ (pearl-delete-current-issue)
+ (goto-char (point-min))
+ (should-not (re-search-forward "Doomed" nil t))
+ ;; the sibling issue is untouched
+ (goto-char (point-min))
+ (should (re-search-forward "Survivor" nil t)))))
+
+(ert-deftest test-pearl-delete-current-issue-declined-keeps-subtree ()
+ "Declining the confirmation makes no API call and leaves the subtree."
+ (let ((called nil))
+ (test-pearl--in-org
+ "*** TODO ENG-1 Keepme\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nbody\n"
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) nil))
+ ((symbol-function 'pearl--delete-issue-async)
+ (lambda (&rest _) (setq called t))))
+ (pearl-delete-current-issue)
+ (should-not called)
+ (goto-char (point-min))
+ (should (re-search-forward "Keepme" nil t))))))
+
+(ert-deftest test-pearl-delete-current-issue-not-on-issue-errors ()
+ "Deleting outside a Linear issue heading signals a user error."
+ (test-pearl--in-org "* Plain heading\nno id\n"
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (&rest _) t)))
+ (should-error (pearl-delete-current-issue) :type 'user-error))))
+
+(provide 'test-pearl-delete)
+;;; test-pearl-delete.el ends here
diff --git a/tests/test-pearl-fields.el b/tests/test-pearl-fields.el
new file mode 100644
index 0000000..7322e4f
--- /dev/null
+++ b/tests/test-pearl-fields.el
@@ -0,0 +1,153 @@
+;;; test-pearl-fields.el --- Tests for command-managed drawer fields -*- 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:
+
+;; Tests for the command-managed drawer fields that need no name->id
+;; resolution helper: set-priority and set-state. Covers the generic
+;; issueUpdate helper (stubbed at the HTTP boundary), the heading cookie and
+;; keyword/drawer mutators, and the two commands' push + buffer-update paths.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; --update-issue-async (generic issueUpdate)
+
+(ert-deftest test-pearl-update-issue-async-success ()
+ "A successful generic issueUpdate reports success and the timestamp."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . t) (issue (id . "a") (updatedAt . "t1")))))
+ (let (result)
+ (pearl--update-issue-async "a" '(("priority" . 2)) (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success)))
+ (should (string= "t1" (plist-get result :updated-at))))))
+
+(ert-deftest test-pearl-update-issue-async-soft-fail ()
+ "A non-success generic issueUpdate reports failure rather than erroring."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . :json-false) (issue . nil))))
+ (let ((called nil) result)
+ (pearl--update-issue-async "a" '(("priority" . 2)) (lambda (r) (setq called t result r)))
+ (should called)
+ (should-not (plist-get result :success)))))
+
+;;; --set-priority-cookie
+
+(ert-deftest test-pearl-set-priority-cookie-replaces ()
+ "Setting a priority rewrites the heading cookie."
+ (test-pearl--in-org "*** TODO [#C] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (pearl--set-priority-cookie 1)
+ (goto-char (point-min))
+ (should (string-match-p "^\\*\\*\\* TODO \\[#A\\] Title" (thing-at-point 'line t)))))
+
+(ert-deftest test-pearl-set-priority-cookie-low ()
+ "Low priority renders the #D cookie."
+ (test-pearl--in-org "*** TODO [#C] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (pearl--set-priority-cookie 4)
+ (goto-char (point-min))
+ (should (string-match-p "^\\*\\*\\* TODO \\[#D\\] Title" (thing-at-point 'line t)))))
+
+(ert-deftest test-pearl-set-priority-cookie-none-removes ()
+ "Priority None removes the cookie."
+ (test-pearl--in-org "*** TODO [#C] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (pearl--set-priority-cookie 0)
+ (goto-char (point-min))
+ (should (string-match-p "^\\*\\*\\* TODO Title" (thing-at-point 'line t)))
+ (should-not (string-match-p "\\[#" (thing-at-point 'line t)))))
+
+;;; pearl-set-priority
+
+(ert-deftest test-pearl-set-priority-pushes-and-updates-cookie ()
+ "Setting priority pushes the numeric value and rewrites the cookie."
+ (let ((pushed nil))
+ (test-pearl--in-org "*** TODO [#C] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t)))))
+ (re-search-forward "Title")
+ (pearl-set-priority "High")
+ (should (equal 2 (cdr (assoc "priority" pushed))))
+ (goto-char (point-min))
+ (should (string-match-p "\\[#B\\]" (thing-at-point 'line t)))))))
+
+(ert-deftest test-pearl-set-priority-not-on-issue-errors ()
+ "Setting priority outside a Linear issue heading signals a user error."
+ (test-pearl--in-org "* Plain heading\nno id\n"
+ (should-error (pearl-set-priority "High") :type 'user-error)))
+
+;;; --set-heading-state
+
+(ert-deftest test-pearl-set-heading-state-updates-keyword-and-drawer ()
+ "Setting the heading state updates the TODO keyword and the LINEAR-STATE drawer."
+ (test-pearl--in-org
+ "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-STATE-ID: old\n:LINEAR-STATE-NAME: Todo\n:END:\n"
+ (pearl--set-heading-state "In Progress" "s2")
+ (goto-char (point-min))
+ (should (string-match-p "^\\*\\*\\* IN-PROGRESS " (thing-at-point 'line t)))
+ (should (string= "In Progress" (org-entry-get nil "LINEAR-STATE-NAME")))
+ (should (string= "s2" (org-entry-get nil "LINEAR-STATE-ID")))))
+
+(ert-deftest test-pearl-set-heading-state-does-not-fire-sync-hook ()
+ "Setting the keyword must not trigger the Linear org-todo sync hook."
+ (let ((fired nil))
+ (test-pearl--in-org
+ "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (let ((org-after-todo-state-change-hook (list (lambda () (setq fired t)))))
+ (pearl--set-heading-state "Done" "s3")
+ (should-not fired)))))
+
+;;; pearl-set-state
+
+(ert-deftest test-pearl-set-state-pushes-id-and-updates-heading ()
+ "Setting state resolves the name to an id, pushes it, and updates the heading."
+ (let ((pushed nil))
+ (test-pearl--in-org
+ "*** TODO [#B] Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TEAM-ID: team-1\n:END:\n"
+ (cl-letf (((symbol-function 'pearl--team-states)
+ (lambda (_team) '(((id . "s1") (name . "Todo"))
+ ((id . "s2") (name . "In Progress")))))
+ ((symbol-function 'pearl--update-issue-async)
+ (lambda (_id input cb) (setq pushed input) (funcall cb '(:success t)))))
+ (pearl-set-state "In Progress")
+ (should (string= "s2" (cdr (assoc "stateId" pushed))))
+ (goto-char (point-min))
+ (should (string-match-p "^\\*\\*\\* IN-PROGRESS " (thing-at-point 'line t)))
+ (should (string= "In Progress" (org-entry-get nil "LINEAR-STATE-NAME")))))))
+
+(ert-deftest test-pearl-set-state-not-on-issue-errors ()
+ "Setting state outside a Linear issue heading signals a user error."
+ (test-pearl--in-org "* Plain heading\nno id\n"
+ (should-error (pearl-set-state "Done") :type 'user-error)))
+
+(provide 'test-pearl-fields)
+;;; test-pearl-fields.el ends here
diff --git a/tests/test-pearl-filter.el b/tests/test-pearl-filter.el
new file mode 100644
index 0000000..6143311
--- /dev/null
+++ b/tests/test-pearl-filter.el
@@ -0,0 +1,193 @@
+;;; test-pearl-filter.el --- Tests for the issue filter DSL -*- 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:
+
+;; Tests for the Layer 1 issue-filter DSL: `pearl--build-issue-filter'
+;; (and its predicate helpers) and `pearl--validate-issue-filter'. All
+;; pure -- no network. Each authoring key is checked in isolation, then in
+;; combination (sibling clauses AND-ed), with `:state'/`:open' precedence and a
+;; json-encode round-trip; validation covers the error cases.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'json)
+
+;;; predicate helpers
+
+(ert-deftest test-pearl-filter-eq-helper ()
+ "`--eq' wraps a value in an eq comparator."
+ (should (equal (pearl--eq "x") '(("eq" . "x"))))
+ (should (equal (pearl--eq t) '(("eq" . t)))))
+
+(ert-deftest test-pearl-filter-in-nin-helpers-make-vectors ()
+ "`--in' / `--nin' encode their values as JSON arrays (vectors)."
+ (should (equal (pearl--in '("a" "b")) '(("in" . ["a" "b"]))))
+ (should (equal (pearl--nin '("a")) '(("nin" . ["a"])))))
+
+;;; compile-priority
+
+(ert-deftest test-pearl-filter-compile-priority-symbol-and-int ()
+ "Priority symbols map to numbers; integers pass through."
+ (should (= 1 (pearl--compile-priority 'urgent)))
+ (should (= 0 (pearl--compile-priority 'none)))
+ (should (= 4 (pearl--compile-priority 'low)))
+ (should (= 3 (pearl--compile-priority 3))))
+
+;;; build-issue-filter -- single dimensions (Normal)
+
+(ert-deftest test-pearl-filter-assignee-me ()
+ ":assignee :me compiles to assignee.isMe.eq true."
+ (should (equal (pearl--build-issue-filter '(:assignee :me))
+ '(("assignee" ("isMe" ("eq" . t)))))))
+
+(ert-deftest test-pearl-filter-assignee-email ()
+ ":assignee with an email compiles to assignee.email.eq."
+ (should (equal (pearl--build-issue-filter '(:assignee "x@y.com"))
+ '(("assignee" ("email" ("eq" . "x@y.com")))))))
+
+(ert-deftest test-pearl-filter-open ()
+ ":open t compiles to state.type nin the closed types."
+ (should (equal (pearl--build-issue-filter '(:open t))
+ '(("state" ("type" ("nin" . ["completed" "canceled" "duplicate"])))))))
+
+(ert-deftest test-pearl-filter-state-name ()
+ ":state compiles to state.name.eq."
+ (should (equal (pearl--build-issue-filter '(:state "In Progress"))
+ '(("state" ("name" ("eq" . "In Progress")))))))
+
+(ert-deftest test-pearl-filter-state-type-list ()
+ ":state-type with a list compiles to state.type.in."
+ (should (equal (pearl--build-issue-filter '(:state-type ("started" "unstarted")))
+ '(("state" ("type" ("in" . ["started" "unstarted"])))))))
+
+(ert-deftest test-pearl-filter-state-type-single ()
+ ":state-type with a bare string is wrapped into a one-element array."
+ (should (equal (pearl--build-issue-filter '(:state-type "started"))
+ '(("state" ("type" ("in" . ["started"])))))))
+
+(ert-deftest test-pearl-filter-project-team-cycle ()
+ ":project / :cycle compile to id.eq; :team to key.eq."
+ (should (equal (pearl--build-issue-filter '(:project "p-1"))
+ '(("project" ("id" ("eq" . "p-1"))))))
+ (should (equal (pearl--build-issue-filter '(:team "ENG"))
+ '(("team" ("key" ("eq" . "ENG"))))))
+ (should (equal (pearl--build-issue-filter '(:cycle "c-1"))
+ '(("cycle" ("id" ("eq" . "c-1")))))))
+
+(ert-deftest test-pearl-filter-labels-any-of ()
+ ":labels compiles to labels.some.name.in (carries any of the listed labels)."
+ (should (equal (pearl--build-issue-filter '(:labels ("bug" "p1")))
+ '(("labels" ("some" ("name" ("in" . ["bug" "p1"]))))))))
+
+(ert-deftest test-pearl-filter-priority-symbol ()
+ ":priority symbol compiles to priority.eq with the numeric value."
+ (should (equal (pearl--build-issue-filter '(:priority high))
+ '(("priority" ("eq" . 2))))))
+
+;;; precedence (:state / :state-type win over :open)
+
+(ert-deftest test-pearl-filter-explicit-state-beats-open ()
+ "An explicit :state overrides :open."
+ (should (equal (pearl--build-issue-filter '(:open t :state "Done"))
+ '(("state" ("name" ("eq" . "Done")))))))
+
+(ert-deftest test-pearl-filter-state-type-beats-open ()
+ ":state-type overrides :open."
+ (should (equal (pearl--build-issue-filter '(:open t :state-type ("started")))
+ '(("state" ("type" ("in" . ["started"])))))))
+
+;;; composition (sibling clauses AND-ed) + ordering keys ignored
+
+(ert-deftest test-pearl-filter-composition-keeps-all-clauses ()
+ "Multiple keys produce sibling clauses; :sort/:order don't affect the filter."
+ (let ((f (pearl--build-issue-filter
+ '(:assignee :me :open t :project "p-1" :labels ("bug")
+ :priority urgent :sort updated :order desc))))
+ (should (assoc "assignee" f))
+ (should (assoc "state" f))
+ (should (assoc "project" f))
+ (should (assoc "labels" f))
+ (should (assoc "priority" f))
+ ;; ordering keys are not part of the IssueFilter
+ (should-not (assoc "sort" f))
+ (should-not (assoc "order" f))))
+
+;;; boundary
+
+(ert-deftest test-pearl-filter-empty-plist-empty-filter ()
+ "An empty plist compiles to an empty filter."
+ (should (null (pearl--build-issue-filter '()))))
+
+(ert-deftest test-pearl-filter-priority-zero-kept ()
+ ":priority 0 (none) is kept, not treated as absent."
+ (should (equal (pearl--build-issue-filter '(:priority 0))
+ '(("priority" ("eq" . 0))))))
+
+;;; json-encode round-trip (proves the alist shape renders the right JSON)
+
+(ert-deftest test-pearl-filter-json-encodes-as-expected ()
+ "The compiled filter json-encodes to the expected IssueFilter JSON."
+ (should (string= (json-encode (pearl--build-issue-filter '(:assignee :me :open t)))
+ (concat "{\"assignee\":{\"isMe\":{\"eq\":true}},"
+ "\"state\":{\"type\":{\"nin\":"
+ "[\"completed\",\"canceled\",\"duplicate\"]}}}"))))
+
+;;; validation (Error cases)
+
+(ert-deftest test-pearl-filter-validate-accepts-good-filter ()
+ "A well-formed filter validates to t."
+ (should (eq t (pearl--validate-issue-filter
+ '(:assignee :me :open t :priority high :labels ("bug") :order desc)))))
+
+(ert-deftest test-pearl-filter-validate-rejects-unknown-key ()
+ "An unknown key is a user-error."
+ (should-error (pearl--validate-issue-filter '(:bogus 1)) :type 'user-error))
+
+(ert-deftest test-pearl-filter-validate-rejects-odd-plist ()
+ "A plist with an odd number of elements is a user-error."
+ (should-error (pearl--validate-issue-filter '(:open)) :type 'user-error))
+
+(ert-deftest test-pearl-filter-validate-rejects-bad-priority-symbol ()
+ "An unrecognized priority symbol is a user-error."
+ (should-error (pearl--validate-issue-filter '(:priority huge)) :type 'user-error))
+
+(ert-deftest test-pearl-filter-validate-rejects-priority-out-of-range ()
+ "A priority integer outside 0-4 is a user-error."
+ (should-error (pearl--validate-issue-filter '(:priority 9)) :type 'user-error))
+
+(ert-deftest test-pearl-filter-validate-rejects-bad-assignee ()
+ "An :assignee that is neither :me nor a string is a user-error."
+ (should-error (pearl--validate-issue-filter '(:assignee 42)) :type 'user-error))
+
+(ert-deftest test-pearl-filter-validate-rejects-empty-string ()
+ "An empty string for a value key is a user-error."
+ (should-error (pearl--validate-issue-filter '(:project "")) :type 'user-error))
+
+(ert-deftest test-pearl-filter-validate-rejects-bad-order ()
+ "An :order other than asc/desc is a user-error."
+ (should-error (pearl--validate-issue-filter '(:order sideways)) :type 'user-error))
+
+(ert-deftest test-pearl-filter-validate-rejects-non-string-label ()
+ "A non-string entry in :labels is a user-error."
+ (should-error (pearl--validate-issue-filter '(:labels ("bug" 7))) :type 'user-error))
+
+(provide 'test-pearl-filter)
+;;; test-pearl-filter.el ends here
diff --git a/tests/test-pearl-fixtures.el b/tests/test-pearl-fixtures.el
new file mode 100644
index 0000000..dfd9e1d
--- /dev/null
+++ b/tests/test-pearl-fixtures.el
@@ -0,0 +1,68 @@
+;;; test-pearl-fixtures.el --- smoke tests for the API fixtures -*- 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:
+
+;; Smoke tests for `testutil-fixtures'. The fixtures exist for the upcoming
+;; query/representation tests, which don't exist yet, so without this file the
+;; suite would never load the fixtures and a syntax slip or shape change would
+;; rot unnoticed. These assertions exercise the file and lock the key shapes
+;; the consuming tests will rely on (json-read conventions: missing keys for
+;; absent fields, `t' / `:json-false' booleans).
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-fixtures (expand-file-name "testutil-fixtures.el"))
+
+(ert-deftest test-pearl-fixture-assigned-page-shape ()
+ "The assignedIssues page fixture has nodes and a pageInfo."
+ (let* ((page (testutil-linear-fixture-assigned-issues-page))
+ (conn (cdr (assoc 'assignedIssues
+ (assoc 'viewer (assoc 'data page))))))
+ (should (= 2 (length (cdr (assoc 'nodes conn)))))
+ (should (eq :json-false (cdr (assoc 'hasNextPage (assoc 'pageInfo conn)))))))
+
+(ert-deftest test-pearl-fixture-null-fields-omits-optionals ()
+ "The null-fields issue carries empty/absent project, assignee, and labels."
+ (let ((issue (testutil-linear-fixture-issue-null-fields)))
+ ;; present-but-empty: the key exists with a nil value (JSON null)
+ (should (assoc 'project issue))
+ (should (null (cdr (assoc 'project issue))))
+ (should (null (cdr (assoc 'assignee issue))))
+ ;; labels is an empty connection, not missing
+ (should (null (cdr (assoc 'nodes (assoc 'labels issue)))))))
+
+(ert-deftest test-pearl-fixture-custom-views-shape ()
+ "The customViews fixture exposes named views with a shared flag."
+ (let* ((views (cdr (assoc 'nodes (assoc 'customViews
+ (assoc 'data (testutil-linear-fixture-custom-views)))))))
+ (should (= 2 (length views)))
+ (should (string-equal "My open work" (cdr (assoc 'name (car views)))))
+ (should (eq t (cdr (assoc 'shared (cadr views)))))))
+
+(ert-deftest test-pearl-fixture-comments-oldest-first ()
+ "The issue-with-comments fixture orders comments oldest first."
+ (let ((comments (cdr (assoc 'nodes (assoc 'comments
+ (testutil-linear-fixture-issue-with-comments))))))
+ (should (= 2 (length comments)))
+ (should (string-equal "First comment" (cdr (assoc 'body (car comments)))))))
+
+(provide 'test-pearl-fixtures)
+;;; test-pearl-fixtures.el ends here
diff --git a/tests/test-pearl-format.el b/tests/test-pearl-format.el
new file mode 100644
index 0000000..7310413
--- /dev/null
+++ b/tests/test-pearl-format.el
@@ -0,0 +1,188 @@
+;;; test-pearl-format.el --- Tests for org entry rendering -*- 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:
+
+;; Tests for the org renderer: `pearl--format-issue-as-org-entry' (a
+;; normalized issue plist -> heading + LINEAR-* drawer + body description),
+;; `pearl--description-to-org-body' (the interim heading guard), and
+;; `pearl--build-org-content'. Issues come from the shared fixtures via
+;; `pearl--normalize-issue', so the renderer is exercised on the same
+;; shapes production hands it.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-fixtures (expand-file-name "testutil-fixtures.el"))
+
+(defmacro test-pearl--with-default-mapping (&rest body)
+ "Run BODY with the default state mapping and a clean pattern cache."
+ (declare (indent 0))
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS")
+ ("In Review" . "IN-REVIEW") ("Backlog" . "BACKLOG")
+ ("Blocked" . "BLOCKED") ("Done" . "DONE")))
+ (pearl-todo-states-pattern nil)
+ (pearl--todo-states-pattern-source nil))
+ ,@body))
+
+(defun test-pearl--norm-full ()
+ "A normalized fully-populated issue."
+ (pearl--normalize-issue (testutil-linear-fixture-issue-full)))
+
+(defun test-pearl--norm-bare ()
+ "A normalized issue with null/missing optional fields."
+ (pearl--normalize-issue (testutil-linear-fixture-issue-null-fields)))
+
+;;; format-issue-as-org-entry
+
+(ert-deftest test-pearl-format-issue-full-renders-heading-and-drawer ()
+ "A full issue renders the heading and the namespaced LINEAR-* drawer."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-full))))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] Fix the thing$" out))
+ (should (string-match-p "^:LINEAR-ID: +uuid-1$" out))
+ (should (string-match-p "^:LINEAR-IDENTIFIER: +ENG-42$" out))
+ (should (string-match-p "^:LINEAR-STATE-NAME: +In Progress$" out))
+ (should (string-match-p "^:LINEAR-TEAM-NAME: +Engineering$" out))
+ (should (string-match-p "^:LINEAR-PROJECT-NAME: +Platform$" out))
+ (should (string-match-p "^:LINEAR-ASSIGNEE-NAME: +Craig$" out))
+ (should (string-match-p "^:LINEAR-LABELS: +\\[bug, backend\\]$" out))
+ (should (string-match-p "^:LINEAR-DESC-SHA256: +[0-9a-f]\\{64\\}$" out))
+ (should (string-match-p "^:LINEAR-TITLE-SHA256: +[0-9a-f]\\{64\\}$" out))
+ (should (string-match-p "^:END:$" out)))))
+
+(ert-deftest test-pearl-format-issue-description-in-body-not-property ()
+ "The description renders as body text, not a :DESCRIPTION: property."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-full))))
+ (should-not (string-match-p ":DESCRIPTION:" out))
+ (should (string-match-p "Line one" out))
+ (should (string-match-p "Line two" out)))))
+
+(ert-deftest test-pearl-format-issue-bare-empty-optionals ()
+ "Null/missing optional fields render as empty values, and the body is empty."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--format-issue-as-org-entry (test-pearl--norm-bare))))
+ (should (string-match-p "^\\*\\* TODO \\[#C\\] Bare issue$" out))
+ (should (string-match-p "^:LINEAR-PROJECT-NAME: +$" out))
+ (should (string-match-p "^:LINEAR-ASSIGNEE-NAME: +$" out))
+ (should (string-match-p "^:LINEAR-LABELS: +\\[\\]$" out))
+ ;; null description -> nothing after :END:
+ (should (string-match-p ":END:\n\\'" out)))))
+
+(ert-deftest test-pearl-format-issue-strips-brackets-from-title ()
+ "Square brackets in the title are stripped so org parsing stays sane."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--format-issue-as-org-entry
+ '(:id "u" :identifier "ENG-1" :title "Fix [URGENT] bug"
+ :priority 1 :state (:name "Todo")))))
+ (should (string-match-p "^\\*\\* TODO \\[#A\\] Fix URGENT bug$" out))
+ ;; the title provenance hash is of the stripped (rendered) title, so a
+ ;; later no-op title sync matches the heading and never clobbers brackets
+ (should (string-match-p
+ (format "^:LINEAR-TITLE-SHA256: +%s$" (secure-hash 'sha256 "Fix URGENT bug"))
+ out)))))
+
+;;; build-org-content
+
+(ert-deftest test-pearl-build-org-content-empty-issues-header-only ()
+ "With no issues the content is the file header plus the empty parent, no entries."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--build-org-content '())))
+ (should (string-match-p "^#\\+title:" out))
+ (should-not (string-match-p "^\\*\\* " out)))))
+
+(ert-deftest test-pearl-build-org-content-no-hardcoded-filetags ()
+ "The header carries no hardcoded =#+filetags= (a personal value used to leak in)."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--build-org-content '())))
+ (should-not (string-match-p "#\\+filetags" out))
+ (should-not (string-match-p "twai" out)))))
+
+(ert-deftest test-pearl-build-org-content-renders-view-parent-heading ()
+ "Issues nest under a single top-level heading named after the view, so they
+sort together (org-sort on the parent) instead of being orphan headings."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--build-org-content
+ (list (test-pearl--norm-full))
+ '(:type filter :name "My open issues" :filter nil))))
+ (should (string-match-p "^\\* My open issues$" out))
+ ;; the parent precedes the issue, which renders one level deeper
+ (should (< (string-match "^\\* My open issues$" out)
+ (string-match "^\\*\\* IN-PROGRESS" out))))))
+
+(ert-deftest test-pearl-build-org-content-startup-show3levels ()
+ "The page opens folded to headings (parent, issues, Comments), bodies hidden."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--build-org-content '())))
+ (should (string-match-p "^#\\+STARTUP: show3levels$" out))
+ (should-not (string-match-p "^#\\+STARTUP: overview$" out)))))
+
+(ert-deftest test-pearl-build-org-content-no-shared-file-id ()
+ "The file header carries no hardcoded org :ID: drawer."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--build-org-content '())))
+ (should-not (string-match-p "a12acb12" out))
+ (should-not (string-match-p "^:PROPERTIES:$" out)))))
+
+(ert-deftest test-pearl-build-org-content-includes-each-issue ()
+ "Each issue contributes one heading to the rendered content."
+ (test-pearl--with-default-mapping
+ (let ((out (pearl--build-org-content
+ (list (test-pearl--norm-full) (test-pearl--norm-bare)))))
+ (should (string-match-p "^\\*\\* IN-PROGRESS \\[#B\\] Fix the thing$" out))
+ (should (string-match-p "^\\*\\* TODO \\[#C\\] Bare issue$" out)))))
+
+;;; --restore-page-visibility
+
+(defun test-pearl--line-visible-p (re)
+ "Non-nil when the line matching RE from point-min is not folded away."
+ (save-excursion
+ (goto-char (point-min))
+ (and (re-search-forward re nil t)
+ (not (invisible-p (line-beginning-position))))))
+
+(ert-deftest test-pearl-restore-page-visibility-folds-bodies-keeps-headings ()
+ "After a repopulation the page folds to headings: parent and issues stay
+visible while property drawers fold away."
+ (test-pearl--with-default-mapping
+ (let ((pearl-fold-after-update t))
+ (with-temp-buffer
+ (insert (pearl--build-org-content (list (test-pearl--norm-full))))
+ (org-mode)
+ (org-fold-show-all)
+ (pearl--restore-page-visibility)
+ (should (test-pearl--line-visible-p "^\\* "))
+ (should (test-pearl--line-visible-p "^\\*\\* IN-PROGRESS"))
+ (should-not (test-pearl--line-visible-p "^:LINEAR-ID:"))))))
+
+(ert-deftest test-pearl-restore-page-visibility-noop-when-disabled ()
+ "With `pearl-fold-after-update' nil the buffer is left fully expanded."
+ (test-pearl--with-default-mapping
+ (let ((pearl-fold-after-update nil))
+ (with-temp-buffer
+ (insert (pearl--build-org-content (list (test-pearl--norm-full))))
+ (org-mode)
+ (org-fold-show-all)
+ (pearl--restore-page-visibility)
+ (should (test-pearl--line-visible-p "^:LINEAR-ID:"))))))
+
+(provide 'test-pearl-format)
+;;; test-pearl-format.el ends here
diff --git a/tests/test-pearl-issues.el b/tests/test-pearl-issues.el
new file mode 100644
index 0000000..0976e0a
--- /dev/null
+++ b/tests/test-pearl-issues.el
@@ -0,0 +1,73 @@
+;;; test-pearl-issues.el --- Tests for pearl issue fetch/create -*- 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:
+
+;; Tests for issue creation with `request' stubbed, focused on
+;; `pearl--created-issue' (the success-checking helper that prevents
+;; phantom "created" reports) and the create-issue async path.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+
+;;; pearl--created-issue (pure)
+
+(ert-deftest test-pearl-created-issue-success-returns-node ()
+ "A successful create returns the issue node."
+ (let ((issue (pearl--created-issue
+ '((data (issueCreate (success . t)
+ (issue (id . "i1") (identifier . "ENG-1") (title . "T"))))))))
+ (should (string-equal "ENG-1" (cdr (assoc 'identifier issue))))))
+
+(ert-deftest test-pearl-created-issue-soft-fail-returns-nil ()
+ "A soft failure (success=false, issue=null) returns nil, not a phantom node."
+ (should (null (pearl--created-issue
+ '((data (issueCreate (success . :json-false) (issue))))))))
+
+(ert-deftest test-pearl-created-issue-graphql-error-returns-nil ()
+ "A GraphQL error body (no data) returns nil."
+ (should (null (pearl--created-issue '((errors . (((message . "bad")))))))))
+
+(ert-deftest test-pearl-created-issue-empty-response-returns-nil ()
+ "A nil/empty response returns nil."
+ (should (null (pearl--created-issue nil))))
+
+;;; pearl-create-issue-async
+
+(ert-deftest test-pearl-create-issue-async-success-passes-issue ()
+ "A successful create passes the issue node to the callback."
+ (let ((got 'unset))
+ (testutil-linear-with-response
+ '((data (issueCreate (success . t)
+ (issue (id . "i1") (identifier . "ENG-1") (title . "T")))))
+ (pearl-create-issue-async "T" "" "team" (lambda (i) (setq got i))))
+ (should (string-equal "ENG-1" (cdr (assoc 'identifier got))))))
+
+(ert-deftest test-pearl-create-issue-async-soft-fail-passes-nil ()
+ "A soft failure passes nil to the callback rather than a phantom issue."
+ (let ((got 'unset))
+ (testutil-linear-with-response
+ '((data (issueCreate (success . :json-false) (issue))))
+ (pearl-create-issue-async "T" "" "team" (lambda (i) (setq got i))))
+ (should (null got))))
+
+(provide 'test-pearl-issues)
+;;; test-pearl-issues.el ends here
diff --git a/tests/test-pearl-mapping.el b/tests/test-pearl-mapping.el
new file mode 100644
index 0000000..a4e02e7
--- /dev/null
+++ b/tests/test-pearl-mapping.el
@@ -0,0 +1,145 @@
+;;; test-pearl-mapping.el --- Tests for pearl mapping helpers -*- 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 pure mapping helpers in pearl.el:
+;; Linear state <-> org keyword, the cached TODO-states regex, and
+;; Linear priority -> org cookie / readable name.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;; A small, explicit mapping used across the state tests so cases don't
+;; depend on the package default.
+(defmacro test-pearl--with-mapping (mapping &rest body)
+ "Run BODY with the state mapping bound to MAPPING and the cache cleared."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping ,mapping)
+ (pearl-todo-states-pattern nil)
+ (pearl--todo-states-pattern-source nil))
+ ,@body))
+
+;;; pearl--map-linear-state-to-org
+
+(ert-deftest test-pearl-map-linear-state-to-org-mapped-returns-keyword ()
+ "A Linear state present in the mapping returns its org keyword."
+ (test-pearl--with-mapping '(("Todo" . "TODO") ("In Progress" . "DOING"))
+ (should (string-equal "DOING" (pearl--map-linear-state-to-org "In Progress")))))
+
+(ert-deftest test-pearl-map-linear-state-to-org-unmapped-defaults-todo ()
+ "An unmapped Linear state falls back to TODO."
+ (test-pearl--with-mapping '(("Todo" . "TODO") ("Done" . "DONE"))
+ (should (string-equal "TODO" (pearl--map-linear-state-to-org "Triage")))))
+
+(ert-deftest test-pearl-map-linear-state-to-org-nil-defaults-todo ()
+ "A nil state falls back to TODO rather than erroring."
+ (test-pearl--with-mapping '(("Todo" . "TODO"))
+ (should (string-equal "TODO" (pearl--map-linear-state-to-org nil)))))
+
+;;; pearl--map-org-state-to-linear
+
+(ert-deftest test-pearl-map-org-state-to-linear-mapped-returns-state ()
+ "An org keyword present in the mapping returns its Linear state."
+ (test-pearl--with-mapping '(("Todo" . "TODO") ("In Progress" . "DOING"))
+ (should (string-equal "In Progress" (pearl--map-org-state-to-linear "DOING")))))
+
+(ert-deftest test-pearl-map-org-state-to-linear-unmapped-returns-nil ()
+ "An org keyword not in the mapping returns nil."
+ (test-pearl--with-mapping '(("Todo" . "TODO") ("Done" . "DONE"))
+ (should (null (pearl--map-org-state-to-linear "WAITING")))))
+
+(ert-deftest test-pearl-map-org-state-to-linear-nil-returns-nil ()
+ "A nil org keyword returns nil."
+ (test-pearl--with-mapping '(("Todo" . "TODO"))
+ (should (null (pearl--map-org-state-to-linear nil)))))
+
+;;; pearl--get-todo-states-pattern
+
+(ert-deftest test-pearl-get-todo-states-pattern-builds-alternation ()
+ "The pattern is the org keywords joined with regex alternation."
+ (test-pearl--with-mapping '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE"))
+ (should (string-equal "TODO\\|IN-PROGRESS\\|DONE"
+ (pearl--get-todo-states-pattern)))))
+
+(ert-deftest test-pearl-get-todo-states-pattern-empty-mapping ()
+ "An empty mapping yields an empty pattern string."
+ (test-pearl--with-mapping '()
+ (should (string-equal "" (pearl--get-todo-states-pattern)))))
+
+(ert-deftest test-pearl-get-todo-states-pattern-recomputes-after-mapping-change ()
+ "The cached pattern is recomputed when the mapping changes mid-session.
+
+Regression for the stale-cache bug: a once-populated pattern must not
+outlive a change to `pearl-state-to-todo-mapping'."
+ (test-pearl--with-mapping '(("Todo" . "TODO") ("Done" . "DONE"))
+ (should (string-equal "TODO\\|DONE" (pearl--get-todo-states-pattern)))
+ ;; Change the mapping after the pattern has been cached.
+ (setq pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "DOING") ("Done" . "DONE")))
+ (should (string-equal "TODO\\|DOING\\|DONE"
+ (pearl--get-todo-states-pattern)))))
+
+;;; pearl--map-linear-priority-to-org
+
+(ert-deftest test-pearl-map-linear-priority-to-org-known-values ()
+ "Linear priorities 1-4 map to org cookies A-D."
+ (should (string-equal "[#A]" (pearl--map-linear-priority-to-org 1)))
+ (should (string-equal "[#B]" (pearl--map-linear-priority-to-org 2)))
+ (should (string-equal "[#C]" (pearl--map-linear-priority-to-org 3)))
+ (should (string-equal "[#D]" (pearl--map-linear-priority-to-org 4))))
+
+(ert-deftest test-pearl-map-linear-priority-to-org-zero-defaults-c ()
+ "Priority 0 (No priority) falls back to [#C]."
+ (should (string-equal "[#C]" (pearl--map-linear-priority-to-org 0))))
+
+(ert-deftest test-pearl-map-linear-priority-to-org-nil-and-unknown-default-c ()
+ "A nil or out-of-range priority falls back to [#C]."
+ (should (string-equal "[#C]" (pearl--map-linear-priority-to-org nil)))
+ (should (string-equal "[#C]" (pearl--map-linear-priority-to-org 99))))
+
+;;; pearl--get-linear-priority-name
+
+(ert-deftest test-pearl-get-linear-priority-name-known-values ()
+ "Linear priorities 1-4 have readable names."
+ (should (string-equal "Urgent" (pearl--get-linear-priority-name 1)))
+ (should (string-equal "High" (pearl--get-linear-priority-name 2)))
+ (should (string-equal "Medium" (pearl--get-linear-priority-name 3)))
+ (should (string-equal "Low" (pearl--get-linear-priority-name 4))))
+
+(ert-deftest test-pearl-get-linear-priority-name-nil-and-unknown-default-medium ()
+ "A nil, zero, or out-of-range priority falls back to Medium."
+ (should (string-equal "Medium" (pearl--get-linear-priority-name 0)))
+ (should (string-equal "Medium" (pearl--get-linear-priority-name nil)))
+ (should (string-equal "Medium" (pearl--get-linear-priority-name 99))))
+
+;;; pearl-get-priorities
+
+(ert-deftest test-pearl-get-priorities-returns-fixed-alist ()
+ "The priority list maps the five Linear priority names to their integers."
+ (let ((priorities (pearl-get-priorities)))
+ (should (equal 0 (cdr (assoc "No priority" priorities))))
+ (should (equal 1 (cdr (assoc "Urgent" priorities))))
+ (should (equal 2 (cdr (assoc "High" priorities))))
+ (should (equal 3 (cdr (assoc "Medium" priorities))))
+ (should (equal 4 (cdr (assoc "Low" priorities))))))
+
+(provide 'test-pearl-mapping)
+;;; test-pearl-mapping.el ends here
diff --git a/tests/test-pearl-menu.el b/tests/test-pearl-menu.el
new file mode 100644
index 0000000..1362b2e
--- /dev/null
+++ b/tests/test-pearl-menu.el
@@ -0,0 +1,73 @@
+;;; test-pearl-menu.el --- Tests for the transient menu -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for `pearl-menu', the transient dispatcher. The menu is
+;; interactive UI, so these test the integration -- the prefix is a real
+;; command, every suffix dispatches to a bound command, and the key bindings
+;; don't collide -- rather than transient's own rendering behavior.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"
+ (file-name-directory
+ (or load-file-name buffer-file-name))))
+(require 'transient)
+
+(defun test-pearl-menu--suffixes (node)
+ "Collect (KEY . COMMAND) pairs from a transient layout NODE.
+Walks vectors and lists recursively; whenever it reaches a plist
+\(a list whose car is a keyword) it reads :key and :command from it."
+ (cond
+ ((vectorp node)
+ (apply #'append (mapcar #'test-pearl-menu--suffixes
+ (append node nil))))
+ ((and (consp node) (keywordp (car node)))
+ (let ((cmd (plist-get node :command))
+ (key (plist-get node :key)))
+ (when cmd (list (cons key cmd)))))
+ ((consp node)
+ (apply #'append (mapcar #'test-pearl-menu--suffixes node)))
+ (t nil)))
+
+(defun test-pearl-menu--pairs ()
+ "Return the (KEY . COMMAND) pairs declared in `pearl-menu'."
+ (test-pearl-menu--suffixes
+ (get 'pearl-menu 'transient--layout)))
+
+(ert-deftest test-pearl-menu-is-command ()
+ "The dispatcher is defined and is an interactive command."
+ (should (fboundp 'pearl-menu))
+ (should (commandp 'pearl-menu)))
+
+(ert-deftest test-pearl-menu-suffixes-dispatch-to-real-commands ()
+ "Every suffix in the menu names a bound, interactive command.
+This is the regression guard: rename or remove a command and the
+menu entry that still points at it fails here."
+ (let ((pairs (test-pearl-menu--pairs)))
+ (should pairs)
+ (dolist (pair pairs)
+ (let ((cmd (cdr pair)))
+ (should (fboundp cmd))
+ (should (commandp cmd))))))
+
+(ert-deftest test-pearl-menu-keys-are-unique ()
+ "No two suffixes share a key binding."
+ (let* ((pairs (test-pearl-menu--pairs))
+ (keys (delq nil (mapcar #'car pairs))))
+ (should (= (length keys) (length (delete-dups (copy-sequence keys)))))))
+
+(ert-deftest test-pearl-menu-covers-core-commands ()
+ "A representative slice of the command surface is reachable from the menu."
+ (let ((cmds (mapcar #'cdr (test-pearl-menu--pairs))))
+ (dolist (expected '(pearl-list-issues
+ pearl-run-view
+ pearl-run-saved-query
+ pearl-sync-current-issue
+ pearl-set-state
+ pearl-add-comment
+ pearl-new-issue
+ pearl-delete-current-issue))
+ (should (memq expected cmds)))))
+
+(provide 'test-pearl-menu)
+;;; test-pearl-menu.el ends here
diff --git a/tests/test-pearl-merge.el b/tests/test-pearl-merge.el
new file mode 100644
index 0000000..180539e
--- /dev/null
+++ b/tests/test-pearl-merge.el
@@ -0,0 +1,252 @@
+;;; test-pearl-merge.el --- Tests for merge-by-LINEAR-ID refresh -*- 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:
+
+;; Tests for the same-source refresh merge: `pearl--merge-issues-into-buffer'
+;; updates existing issue subtrees in place by LINEAR-ID, appends new matches,
+;; drops issues gone from the result, and protects unpushed local edits (it
+;; neither overwrites nor drops a subtree whose body diverges from its stored
+;; provenance hash). Also covers the header refresh and the `--merge-query-result'
+;; render boundary that drives `pearl-refresh-current-view'.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl-merge--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+(defun test-pearl-merge--issue (id title desc)
+ "A normalized issue plist with ID, TITLE, and DESC for merge input."
+ (list :id id :identifier (concat "ENG-" id) :title title :description desc
+ :priority 2 :url (concat "https://linear.app/x/" id)
+ :updated-at "2026-05-23T03:00:00.000Z"
+ :state (list :id "s1" :name "Todo" :type "unstarted")
+ :team (list :id "t1" :key "ENG" :name "Engineering")))
+
+(defun test-pearl-merge--raw (id title desc)
+ "A raw issue node (json-read shape) with ID, TITLE, and DESC."
+ `((id . ,id) (identifier . ,(concat "ENG-" id)) (title . ,title)
+ (description . ,desc) (priority . 2) (url . ,(concat "https://linear.app/x/" id))
+ (updatedAt . "2026-05-23T03:00:00.000Z")
+ (state (id . "s1") (name . "Todo") (type . "unstarted"))
+ (team (id . "t1") (key . "ENG") (name . "Engineering"))
+ (labels (nodes . []))))
+
+(defun test-pearl-merge--buffer (&rest issues)
+ "A header plus the formatted ISSUES, as the active file would hold them."
+ (concat "#+title: Linear — My open issues\n"
+ "#+LINEAR-SOURCE: (:type filter :name \"My open issues\" :filter (:assignee :me))\n"
+ "#+LINEAR-RUN-AT: 2026-05-01 09:00\n"
+ "#+LINEAR-COUNT: 9\n"
+ "#+LINEAR-TRUNCATED: no\n\n"
+ (mapconcat #'pearl--format-issue-as-org-entry issues "")))
+
+;;; --merge-issues-into-buffer
+
+(ert-deftest test-pearl-merge-updates-existing-in-place ()
+ "An existing issue still in the result is re-rendered from the fetch in place."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha Renamed" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta.")))))
+ (should (= 2 (plist-get counts :updated)))
+ (should (= 0 (plist-get counts :added)))
+ (should (= 0 (plist-get counts :dropped)))
+ (should (= 0 (plist-get counts :skipped)))
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha Renamed" nil t))
+ (should-not (save-excursion (re-search-forward "^\\*\\*\\* .*Alpha$" nil t)))
+ ;; Alpha still precedes Beta — order is stable.
+ (goto-char (point-min))
+ (let ((a (progn (re-search-forward "Alpha Renamed") (point)))
+ (b (progn (re-search-forward "Beta") (point))))
+ (should (< a b))))))
+
+(ert-deftest test-pearl-merge-appends-new-issue ()
+ "An issue new to the result is appended after the existing ones."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta.")))))
+ (should (= 1 (plist-get counts :added)))
+ (goto-char (point-min))
+ (should (string= "a" (progn (re-search-forward "LINEAR-ID: *\\(.*\\)$") (match-string 1))))
+ (should (re-search-forward "LINEAR-ID: *b" nil t))
+ (goto-char (point-min))
+ (should (< (progn (re-search-forward "Alpha") (point))
+ (progn (re-search-forward "Beta") (point)))))))
+
+(ert-deftest test-pearl-merge-drops-absent-issue ()
+ "A clean issue no longer in the result is dropped."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")))))
+ (should (= 1 (plist-get counts :dropped)))
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha" nil t))
+ (goto-char (point-min))
+ (should-not (re-search-forward "Beta" nil t)))))
+
+(ert-deftest test-pearl-merge-keeps-unpushed-edit-on-update ()
+ "An existing subtree with unpushed body edits is kept, not overwritten."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ ;; Dirty the body so its hash no longer matches the stored provenance.
+ (goto-char (point-min))
+ (re-search-forward "Desc Alpha\\.")
+ (end-of-line)
+ (insert " UNPUSHED EDIT")
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha Renamed" "Remote desc.")))))
+ (should (= 1 (plist-get counts :skipped)))
+ (should (= 0 (plist-get counts :updated)))
+ (goto-char (point-min))
+ ;; Local edit and old heading survive; the remote rename did not land.
+ (should (re-search-forward "UNPUSHED EDIT" nil t))
+ (goto-char (point-min))
+ (should-not (re-search-forward "Alpha Renamed" nil t)))))
+
+(ert-deftest test-pearl-merge-keeps-dirty-issue-absent-from-result ()
+ "A dirty issue gone from the result is kept rather than dropped (no data loss)."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha.")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (goto-char (point-min))
+ (re-search-forward "Desc Alpha\\.")
+ (end-of-line)
+ (insert " UNPUSHED EDIT")
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "b" "Beta" "Desc Beta.")))))
+ (should (= 1 (plist-get counts :skipped)))
+ (should (= 0 (plist-get counts :dropped)))
+ (goto-char (point-min))
+ (should (re-search-forward "UNPUSHED EDIT" nil t)))))
+
+(ert-deftest test-pearl-merge-updates-rich-description-issue-in-place ()
+ "An unedited issue with lossy markdown (a heading) is updated, not skipped.
+Regression: the dirty check round-tripped Org back to markdown and mistook a
+lossy round-trip (# heading -> bold, *italic* -> **bold**) for a local edit, so
+refresh silently skipped every rich-text issue."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "# Heading\n\nSome body text."))
+ (let ((counts (pearl--merge-issues-into-buffer
+ (list (test-pearl-merge--issue "a" "Alpha Renamed"
+ "# Heading\n\nSome body text.")))))
+ (should (= 1 (plist-get counts :updated)))
+ (should (= 0 (plist-get counts :skipped)))
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha Renamed" nil t)))))
+
+(ert-deftest test-pearl-subtree-dirty-p-rich-description-unedited-not-dirty ()
+ "A freshly rendered subtree with lossy-markdown description is not dirty unedited."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "# Heading\n\nSome body text."))
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (beginning-of-line)
+ (should-not (pearl--subtree-dirty-p))))
+
+(ert-deftest test-pearl-subtree-dirty-p-empty-description-not-dirty ()
+ "An issue with an empty description is not dirty.
+Regression: body extraction overshot an empty body into the next issue's
+subtree, so every description-less issue read as a local edit."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "")
+ (test-pearl-merge--issue "b" "Beta" "Desc Beta."))
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (beginning-of-line)
+ (should (string= "" (pearl--issue-body-at-point)))
+ (should-not (pearl--subtree-dirty-p))))
+
+(ert-deftest test-pearl-subtree-dirty-p-edited-body-is-dirty ()
+ "Editing the rendered body still marks the subtree dirty (edit detection holds)."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer
+ (test-pearl-merge--issue "a" "Alpha" "# Heading\n\nSome body text."))
+ (goto-char (point-min))
+ (re-search-forward "Some body text\\.")
+ (end-of-line)
+ (insert " LOCAL EDIT")
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ (beginning-of-line)
+ (should (pearl--subtree-dirty-p))))
+
+;;; --update-source-header
+
+(ert-deftest test-pearl-merge-update-source-header-rewrites-count ()
+ "The header refresh updates the count and truncation lines in place."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (pearl--update-source-header 5 t)
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-COUNT: 5$" nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-TRUNCATED: yes$" nil t))))
+
+;;; --merge-query-result (render boundary)
+
+(ert-deftest test-pearl-merge-query-result-merges-and-updates-header ()
+ "An ok result normalizes its raw nodes, merges them, and refreshes the count."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (let ((source '(:type filter :name "My open issues" :filter (:assignee :me)))
+ (result (pearl--make-query-result
+ 'ok :issues (list (test-pearl-merge--raw "a" "Alpha Renamed" "Desc Alpha.")
+ (test-pearl-merge--raw "c" "Gamma" "Desc Gamma.")))))
+ (pearl--merge-query-result result source)
+ (goto-char (point-min))
+ (should (re-search-forward "Alpha Renamed" nil t))
+ (should (re-search-forward "Gamma" nil t))
+ (goto-char (point-min))
+ (should (re-search-forward "^#\\+LINEAR-COUNT: 2$" nil t)))))
+
+(ert-deftest test-pearl-merge-query-result-empty-leaves-buffer ()
+ "An empty result leaves the buffer unchanged rather than dropping everything."
+ (test-pearl-merge--in-org
+ (test-pearl-merge--buffer (test-pearl-merge--issue "a" "Alpha" "Desc Alpha."))
+ (let ((source '(:type filter :name "My open issues" :filter (:assignee :me)))
+ (before (buffer-string)))
+ (pearl--merge-query-result (pearl--make-query-result 'empty) source)
+ (should (string= before (buffer-string))))))
+
+(provide 'test-pearl-merge)
+;;; test-pearl-merge.el ends here
diff --git a/tests/test-pearl-normalize.el b/tests/test-pearl-normalize.el
new file mode 100644
index 0000000..78874ab
--- /dev/null
+++ b/tests/test-pearl-normalize.el
@@ -0,0 +1,138 @@
+;;; test-pearl-normalize.el --- Tests for API model normalization -*- 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:
+
+;; Tests for the normalizers that flatten raw json-read Linear responses into
+;; internal plists. Driven by the shared fixtures so the renderer-facing
+;; contract is locked: vectors become lists, absent/`:json-false' fields become
+;; nil, and a null comment author falls back to the bot/external actor.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-fixtures (expand-file-name "testutil-fixtures.el"))
+
+;;; node-list
+
+(ert-deftest test-pearl-normalize-node-list-vector-to-list ()
+ "A connection's nodes vector is returned as a list."
+ (should (equal '(1 2 3) (pearl--node-list '((nodes . [1 2 3]))))))
+
+(ert-deftest test-pearl-normalize-node-list-empty-and-missing ()
+ "An empty or missing nodes connection yields an empty list."
+ (should (null (pearl--node-list '((nodes . [])))))
+ (should (null (pearl--node-list '((pageInfo . nil))))))
+
+;;; normalize-issue -- fully populated
+
+(ert-deftest test-pearl-normalize-issue-full ()
+ "A full issue normalizes every field, flattening nested objects."
+ (let ((i (pearl--normalize-issue (testutil-linear-fixture-issue-full))))
+ (should (string= "ENG-42" (plist-get i :identifier)))
+ (should (string= "Fix the thing" (plist-get i :title)))
+ (should (= 2 (plist-get i :priority)))
+ (should (string= "In Progress" (plist-get (plist-get i :state) :name)))
+ (should (string= "started" (plist-get (plist-get i :state) :type)))
+ (should (string= "Craig" (plist-get (plist-get i :assignee) :name)))
+ (should (string= "ENG" (plist-get (plist-get i :team) :key)))
+ (should (string= "Platform" (plist-get (plist-get i :project) :name)))
+ (should (string= "Cycle 12" (plist-get (plist-get i :cycle) :name)))
+ ;; labels: vector of nodes -> list of (:id :name) plists
+ (should (equal '("bug" "backend")
+ (mapcar (lambda (l) (plist-get l :name)) (plist-get i :labels))))))
+
+;;; normalize-issue -- null / missing optional fields
+
+(ert-deftest test-pearl-normalize-issue-null-fields ()
+ "Absent or null optional fields normalize to nil, not an error."
+ (let ((i (pearl--normalize-issue (testutil-linear-fixture-issue-null-fields))))
+ (should (string= "ENG-7" (plist-get i :identifier)))
+ (should (null (plist-get i :description)))
+ (should (null (plist-get i :assignee)))
+ (should (null (plist-get i :project)))
+ (should (null (plist-get i :cycle)))
+ (should (null (plist-get i :labels)))
+ ;; state is still present
+ (should (string= "Todo" (plist-get (plist-get i :state) :name)))))
+
+(ert-deftest test-pearl-normalize-issue-nil-input ()
+ "Normalizing nil yields nil."
+ (should (null (pearl--normalize-issue nil))))
+
+(ert-deftest test-pearl-normalize-issue-omits-comments-when-absent ()
+ "An issue fetched without comments has a nil :comments, not an empty list."
+ (let ((i (pearl--normalize-issue (testutil-linear-fixture-issue-full))))
+ (should (null (plist-get i :comments)))))
+
+;;; normalize-comment -- author fallback
+
+(ert-deftest test-pearl-normalize-comment-user-author ()
+ "A comment with a user takes the user's name as author."
+ (let* ((raw (car (pearl--node-list
+ (cdr (assoc 'comments (testutil-linear-fixture-issue-with-comments))))))
+ (c (pearl--normalize-comment raw)))
+ (should (string= "Alice" (plist-get c :author)))
+ (should (string= "First comment" (plist-get c :body)))))
+
+(ert-deftest test-pearl-normalize-comment-null-user-falls-back-to-bot ()
+ "A comment with a null user falls back to the bot actor's name.
+
+`Comment.user' is null for integration/bot comments, so the renderer must not
+assume a user is present."
+ (let ((c (pearl--normalize-comment
+ '((id . "cm-bot") (body . "Deployed") (createdAt . "2026-05-20T00:00:00Z")
+ (user) (botActor . ((name . "GitHub")))))))
+ (should (string= "GitHub" (plist-get c :author)))))
+
+(ert-deftest test-pearl-normalize-comment-null-user-no-actor-nil ()
+ "A null user with no bot or external actor leaves :author nil.
+The renderer is responsible for showing a placeholder; the normalizer reports
+the absence honestly rather than inventing a name."
+ (let ((c (pearl--normalize-comment '((id . "cm-x") (body . "x") (user)))))
+ (should (null (plist-get c :author)))))
+
+(ert-deftest test-pearl-normalize-comment-bot-without-name-default ()
+ "A bot actor with no name falls back to the literal \"automation\"."
+ (let ((c (pearl--normalize-comment
+ '((id . "cm-b") (body . "x") (user) (botActor . ((id . "b1")))))))
+ (should (string= "automation" (plist-get c :author)))))
+
+;;; normalize-custom-view
+
+(ert-deftest test-pearl-normalize-custom-view-personal ()
+ "A personal (shared=false) workspace-wide view: :shared nil, :team nil."
+ (let* ((views (cdr (assoc 'nodes (assoc 'customViews
+ (assoc 'data (testutil-linear-fixture-custom-views))))))
+ (cv (pearl--normalize-custom-view (elt views 0))))
+ (should (string= "My open work" (plist-get cv :name)))
+ (should (null (plist-get cv :shared)))
+ (should (null (plist-get cv :team)))
+ (should (string= "Craig" (plist-get (plist-get cv :owner) :name)))))
+
+(ert-deftest test-pearl-normalize-custom-view-shared-with-team ()
+ "A shared team view: :shared t and a normalized :team plist."
+ (let* ((views (cdr (assoc 'nodes (assoc 'customViews
+ (assoc 'data (testutil-linear-fixture-custom-views))))))
+ (cv (pearl--normalize-custom-view (elt views 1))))
+ (should (eq t (plist-get cv :shared)))
+ (should (string= "ENG" (plist-get (plist-get cv :team) :key)))))
+
+(provide 'test-pearl-normalize)
+;;; test-pearl-normalize.el ends here
diff --git a/tests/test-pearl-open.el b/tests/test-pearl-open.el
new file mode 100644
index 0000000..1be6114
--- /dev/null
+++ b/tests/test-pearl-open.el
@@ -0,0 +1,66 @@
+;;; test-pearl-open.el --- Tests for open-issue-in-browser -*- 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:
+
+;; Tests for `pearl-open-current-issue', which opens the issue at point
+;; in the browser from its LINEAR-URL property. `browse-url' is stubbed.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT at point-min."
+ (declare (indent 1))
+ `(with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body))
+
+(ert-deftest test-pearl-open-current-issue-visits-url ()
+ "The command passes the heading's LINEAR-URL to `browse-url'."
+ (let ((visited nil))
+ (test-pearl--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-URL: https://linear.app/x/ENG-1\n:END:\nbody\n"
+ (cl-letf (((symbol-function 'browse-url)
+ (lambda (url &rest _) (setq visited url))))
+ (goto-char (point-max))
+ (pearl-open-current-issue)
+ (should (string= "https://linear.app/x/ENG-1" visited))))))
+
+(ert-deftest test-pearl-open-current-issue-no-url-errors ()
+ "An issue heading without a LINEAR-URL signals a user error, no browse."
+ (let ((visited nil))
+ (test-pearl--in-org
+ "*** TODO Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (cl-letf (((symbol-function 'browse-url)
+ (lambda (url &rest _) (setq visited url))))
+ (should-error (pearl-open-current-issue) :type 'user-error)
+ (should-not visited)))))
+
+(ert-deftest test-pearl-open-current-issue-not-on-issue-errors ()
+ "Running outside a heading signals a user error."
+ (test-pearl--in-org "plain text, no heading\n"
+ (should-error (pearl-open-current-issue) :type 'user-error)))
+
+(provide 'test-pearl-open)
+;;; test-pearl-open.el ends here
diff --git a/tests/test-pearl-org-parse.el b/tests/test-pearl-org-parse.el
new file mode 100644
index 0000000..87f4a02
--- /dev/null
+++ b/tests/test-pearl-org-parse.el
@@ -0,0 +1,152 @@
+;;; test-pearl-org-parse.el --- Tests for pearl org parsing -*- 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:
+
+;; Tests for the org readers `pearl--extract-org-heading-properties' and
+;; `pearl--process-heading-at-point'. The reader uses org APIs over the
+;; LINEAR-* property drawer, so it works from anywhere in the entry, at any
+;; heading depth, and is unbothered by body text or nested sub-entries. The one
+;; network boundary reached during processing (`--update-issue-state-async') is
+;; stubbed.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
+ (declare (indent 1))
+ ;; Declare the Linear keywords so `org-get-todo-state' recognizes them, the
+ ;; way the generated file's `#+TODO:' line does in real use.
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
+ (pearl-todo-states-pattern nil)
+ (pearl--todo-states-pattern-source nil)
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; extract-org-heading-properties
+
+(ert-deftest test-pearl-extract-heading-properties-full ()
+ "A complete Linear entry yields the todo keyword, ids, and team id."
+ (test-pearl--in-org
+ "*** TODO My issue\n:PROPERTIES:\n:LINEAR-ID: abc-123\n:LINEAR-IDENTIFIER: ENG-5\n:LINEAR-TEAM-ID: team-9\n:END:\n"
+ (re-search-forward "My issue")
+ (let ((props (pearl--extract-org-heading-properties)))
+ (should (string-equal "TODO" (plist-get props :todo-state)))
+ (should (string-equal "abc-123" (plist-get props :issue-id)))
+ (should (string-equal "ENG-5" (plist-get props :issue-identifier)))
+ (should (string-equal "team-9" (plist-get props :team-id))))))
+
+(ert-deftest test-pearl-extract-reads-from-inside-the-entry ()
+ "The reader works with point in the body, not only on the heading line."
+ (test-pearl--in-org
+ "*** TODO My issue\n:PROPERTIES:\n:LINEAR-ID: abc\n:END:\nsome body text here\n"
+ (goto-char (point-max))
+ (should (string-equal "abc" (plist-get (pearl--extract-org-heading-properties) :issue-id)))))
+
+(ert-deftest test-pearl-extract-team-id-is-read-not-looked-up ()
+ "The team id comes straight from LINEAR-TEAM-ID, with no network call."
+ (cl-letf (((symbol-function 'pearl--get-team-id-by-name)
+ (lambda (&rest _) (error "should not be called"))))
+ (test-pearl--in-org
+ "*** TODO x\n:PROPERTIES:\n:LINEAR-ID: i\n:LINEAR-TEAM-ID: t-1\n:END:\n"
+ (re-search-forward "x")
+ (should (string-equal "t-1" (plist-get (pearl--extract-org-heading-properties) :team-id))))))
+
+(ert-deftest test-pearl-extract-missing-id ()
+ "A drawer with no LINEAR-ID yields a nil issue-id."
+ (test-pearl--in-org
+ "*** TODO x\n:PROPERTIES:\n:LINEAR-IDENTIFIER: ENG-9\n:END:\n"
+ (re-search-forward "x")
+ (let ((props (pearl--extract-org-heading-properties)))
+ (should (string-equal "TODO" (plist-get props :todo-state)))
+ (should (null (plist-get props :issue-id)))
+ (should (string-equal "ENG-9" (plist-get props :issue-identifier))))))
+
+(ert-deftest test-pearl-extract-deeper-heading-now-supported ()
+ "A level-4 entry is read the same as level-3 (the reader is depth-agnostic)."
+ (test-pearl--in-org
+ "*** TODO parent\n**** TODO child\n:PROPERTIES:\n:LINEAR-ID: c\n:END:\n"
+ (re-search-forward "child")
+ (should (string-equal "c" (plist-get (pearl--extract-org-heading-properties) :issue-id)))))
+
+(ert-deftest test-pearl-extract-off-heading-nil ()
+ "Before the first heading, nothing is extracted."
+ (test-pearl--in-org
+ "preamble line\n* Top\n"
+ (goto-char (point-min))
+ (should (null (pearl--extract-org-heading-properties)))))
+
+;;; process-heading-at-point
+
+(ert-deftest test-pearl-process-heading-updates-when-complete ()
+ "A complete entry triggers an async state update with the mapped state."
+ (let ((captured nil))
+ (cl-letf (((symbol-function 'pearl--update-issue-state-async)
+ (lambda (id state team) (setq captured (list id state team)))))
+ (test-pearl--in-org
+ "*** IN-PROGRESS x\n:PROPERTIES:\n:LINEAR-ID: i-1\n:LINEAR-IDENTIFIER: ENG-2\n:LINEAR-TEAM-ID: t-1\n:END:\n"
+ (re-search-forward "x")
+ (pearl--process-heading-at-point)
+ (should (equal '("i-1" "In Progress" "t-1") captured))))))
+
+(ert-deftest test-pearl-process-heading-skips-without-team ()
+ "An entry missing its team id makes no API call."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'pearl--update-issue-state-async)
+ (lambda (&rest _) (setq called t))))
+ (test-pearl--in-org
+ "*** TODO x\n:PROPERTIES:\n:LINEAR-ID: i-1\n:LINEAR-IDENTIFIER: ENG-2\n:END:\n"
+ (re-search-forward "x")
+ (pearl--process-heading-at-point)
+ (should-not called)))))
+
+;;; render -> parse round trip
+
+(ert-deftest test-pearl-render-parse-round-trip ()
+ "An issue rendered by build-org-content parses back through the reader.
+Locks the render/parse contract: the LINEAR-* drawer the renderer writes is
+exactly what the reader extracts, and the rendered keyword is recognized."
+ (let ((pearl-state-to-todo-mapping '(("In Progress" . "IN-PROGRESS")))
+ (pearl-todo-states-pattern nil)
+ (pearl--todo-states-pattern-source nil)
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (let ((content (pearl--build-org-content
+ '((:id "i-1" :identifier "ENG-2" :title "round trip"
+ :priority 2 :state (:name "In Progress") :team (:id "t-1"))))))
+ (with-temp-buffer
+ (insert content)
+ (org-mode)
+ (goto-char (point-min))
+ (re-search-forward "round trip")
+ (let ((props (pearl--extract-org-heading-properties)))
+ (should (string-equal "i-1" (plist-get props :issue-id)))
+ (should (string-equal "ENG-2" (plist-get props :issue-identifier)))
+ (should (string-equal "IN-PROGRESS" (plist-get props :todo-state)))
+ (should (string-equal "t-1" (plist-get props :team-id))))))))
+
+(provide 'test-pearl-org-parse)
+;;; test-pearl-org-parse.el ends here
diff --git a/tests/test-pearl-org-write.el b/tests/test-pearl-org-write.el
new file mode 100644
index 0000000..c253190
--- /dev/null
+++ b/tests/test-pearl-org-write.el
@@ -0,0 +1,85 @@
+;;; test-pearl-org-write.el --- Tests for pearl org file write-back -*- 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:
+
+;; Tests for `pearl--update-org-from-issues', the buffer-aware
+;; write-back. Uses real temp files (file I/O is the behavior under test);
+;; the three branches are no-buffer, clean-buffer, and dirty-buffer.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+(defvar test-pearl--sample-issues
+ '((:id "u" :identifier "ENG-1" :title "T" :priority 3 :state (:name "Todo")))
+ "One normalized issue, enough to render a recognizable org entry.")
+
+(defmacro test-pearl--with-org-file (var &rest body)
+ "Bind VAR to a fresh temp .org path and run BODY, cleaning up after.
+The state mapping is bound so rendering is deterministic."
+ (declare (indent 1))
+ `(let* ((,var (make-temp-file "linear-test-" nil ".org"))
+ (pearl-org-file-path ,var)
+ (pearl-state-to-todo-mapping '(("Todo" . "TODO"))))
+ (unwind-protect
+ (progn ,@body)
+ (let ((b (find-buffer-visiting ,var)))
+ (when b
+ (with-current-buffer b (set-buffer-modified-p nil))
+ (kill-buffer b)))
+ (when (file-exists-p ,var) (delete-file ,var)))))
+
+(ert-deftest test-pearl-update-org-no-buffer-writes-file ()
+ "With no buffer visiting the file, issues are written to disk."
+ (test-pearl--with-org-file tmp
+ (let ((b (find-buffer-visiting tmp))) (when b (kill-buffer b)))
+ (pearl--update-org-from-issues test-pearl--sample-issues)
+ (let ((content (with-temp-buffer (insert-file-contents tmp) (buffer-string))))
+ (should (string-match-p "#\\+title: Linear" content))
+ (should (string-match-p "#\\+LINEAR-SOURCE: " content))
+ (should (string-match-p "\\*\\* TODO \\[#C\\] T" content)))))
+
+(ert-deftest test-pearl-update-org-clean-buffer-replaces-contents ()
+ "A clean visiting buffer is replaced in place and saved."
+ (test-pearl--with-org-file tmp
+ (let ((buf (find-file-noselect tmp)))
+ (with-current-buffer buf
+ (insert "old content")
+ (save-buffer))
+ (pearl--update-org-from-issues test-pearl--sample-issues)
+ (with-current-buffer buf
+ (should-not (buffer-modified-p))
+ (should (string-match-p "\\*\\* TODO \\[#C\\] T" (buffer-string)))
+ (should-not (string-match-p "old content" (buffer-string)))))))
+
+(ert-deftest test-pearl-update-org-dirty-buffer-not-overwritten ()
+ "A buffer with unsaved edits is left untouched, not clobbered."
+ (test-pearl--with-org-file tmp
+ (let ((buf (find-file-noselect tmp)))
+ (with-current-buffer buf
+ (insert "unsaved edits"))
+ (pearl--update-org-from-issues test-pearl--sample-issues)
+ (with-current-buffer buf
+ (should (buffer-modified-p))
+ (should (string-match-p "unsaved edits" (buffer-string)))
+ (should-not (string-match-p "ENG-1" (buffer-string)))))))
+
+(provide 'test-pearl-org-write)
+;;; test-pearl-org-write.el ends here
diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el
new file mode 100644
index 0000000..79b7a82
--- /dev/null
+++ b/tests/test-pearl-output.el
@@ -0,0 +1,145 @@
+;;; test-pearl-output.el --- Tests for the active-file output model -*- 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:
+
+;; Tests for the active-file output model: the filter summary, the
+;; source-tracking header (with the affordance preamble) written by
+;; `--build-org-content', reading the active source back from a buffer, and
+;; `pearl-refresh-current-view' re-running the recorded source.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+;;; --summarize-filter
+
+(ert-deftest test-pearl-summarize-filter-fields ()
+ "A filter plist summarizes its set dimensions in a readable string."
+ (let ((s (pearl--summarize-filter '(:assignee :me :open t :state "In Progress"))))
+ (should (string-match-p "assignee" s))
+ (should (string-match-p "open" s))
+ (should (string-match-p "In Progress" s))))
+
+(ert-deftest test-pearl-summarize-filter-empty ()
+ "An empty filter summarizes as all issues."
+ (should (string-match-p "all" (pearl--summarize-filter nil))))
+
+;;; --build-org-content with a source
+
+(ert-deftest test-pearl-build-org-content-source-header ()
+ "With a source, the header carries the title, serialized source, and count."
+ (let* ((source '(:type filter :name "My open issues" :filter (:assignee :me :open t)))
+ (out (pearl--build-org-content '() source)))
+ (should (string-match-p "^#\\+title: Linear — My open issues$" out))
+ (should (string-match-p "^#\\+LINEAR-SOURCE: " out))
+ (should (string-match-p "^#\\+LINEAR-COUNT: 0$" out))
+ ;; affordance preamble is present as org comments, not content
+ (should (string-match-p "^# .*pearl-sync-current-issue" out))))
+
+(ert-deftest test-pearl-build-org-content-source-roundtrips ()
+ "The serialized source in the header reads back to the original plist."
+ (let* ((source '(:type filter :name "Bugs" :filter (:labels ("bug") :open t)))
+ (out (pearl--build-org-content '() source)))
+ (with-temp-buffer
+ (insert out)
+ (should (equal source (pearl--read-active-source))))))
+
+(ert-deftest test-pearl-build-org-content-default-source-back-compat ()
+ "Called with no source, the content still has a title and no entries."
+ (let ((out (pearl--build-org-content '())))
+ (should (string-match-p "^#\\+title:" out))
+ (should-not (string-match-p "^\\*\\*\\* " out))))
+
+;;; --read-active-source
+
+(ert-deftest test-pearl-read-active-source-absent ()
+ "A buffer with no source header reads back nil."
+ (with-temp-buffer
+ (insert "#+title: something\n\n* a heading\n")
+ (should-not (pearl--read-active-source))))
+
+;;; refresh-current-view
+
+(ert-deftest test-pearl-refresh-current-view-reruns-source ()
+ "Refresh reads the recorded filter source and merges the re-run result."
+ (let ((ran nil) (merged-source nil)
+ (source '(:type filter :name "My open issues" :filter (:assignee :me :open t))))
+ (with-temp-buffer
+ (insert (format "#+title: Linear — My open issues\n#+LINEAR-SOURCE: %s\n\n"
+ (prin1-to-string source)))
+ (org-mode)
+ (cl-letf (((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter cb)
+ (setq ran t)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--merge-query-result)
+ (lambda (_result src) (setq merged-source src))))
+ (pearl-refresh-current-view)
+ (should ran)
+ (should (equal source merged-source))))))
+
+(ert-deftest test-pearl-refresh-current-view-no-source-errors ()
+ "Refresh with no recorded source signals a user error."
+ (with-temp-buffer
+ (insert "#+title: plain\n")
+ (org-mode)
+ (should-error (pearl-refresh-current-view) :type 'user-error)))
+
+;;; --update-org-from-issues surfaces the result
+
+(ert-deftest test-pearl-update-org-surfaces-fresh-buffer ()
+ "With no buffer visiting the file, the write creates one and surfaces it."
+ (let* ((tmp (make-temp-file "pearl-out" nil ".org"))
+ (pearl-org-file-path tmp)
+ (surfaced nil))
+ (unwind-protect
+ (progn
+ (when (find-buffer-visiting tmp) (kill-buffer (find-buffer-visiting tmp)))
+ (cl-letf (((symbol-function 'pearl--surface-buffer)
+ (lambda (b) (setq surfaced b))))
+ (pearl--update-org-from-issues '() '(:type filter :name "X" :filter nil) nil))
+ (should (bufferp surfaced))
+ (should (buffer-live-p surfaced))
+ (should (string= (file-truename tmp)
+ (file-truename (buffer-file-name surfaced)))))
+ (when (find-buffer-visiting tmp) (kill-buffer (find-buffer-visiting tmp)))
+ (ignore-errors (delete-file tmp)))))
+
+(ert-deftest test-pearl-update-org-surfaces-existing-buffer ()
+ "With a clean buffer visiting the file, the update surfaces that buffer."
+ (let* ((tmp (make-temp-file "pearl-out" nil ".org"))
+ (pearl-org-file-path tmp)
+ (surfaced nil)
+ (buf (find-file-noselect tmp)))
+ (unwind-protect
+ (progn
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (cl-letf (((symbol-function 'pearl--surface-buffer)
+ (lambda (b) (setq surfaced b))))
+ (pearl--update-org-from-issues '() '(:type filter :name "X" :filter nil) nil))
+ (should (eq surfaced buf)))
+ (when (buffer-live-p buf)
+ (with-current-buffer buf (set-buffer-modified-p nil))
+ (kill-buffer buf))
+ (ignore-errors (delete-file tmp)))))
+
+(provide 'test-pearl-output)
+;;; test-pearl-output.el ends here
diff --git a/tests/test-pearl-query.el b/tests/test-pearl-query.el
new file mode 100644
index 0000000..87e48b9
--- /dev/null
+++ b/tests/test-pearl-query.el
@@ -0,0 +1,151 @@
+;;; test-pearl-query.el --- Tests for the general issue query -*- 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:
+
+;; Tests for `pearl--query-issues-async' and the `--page-issues' pager,
+;; with `--graphql-request-async' stubbed. Cover the query/variable
+;; construction, pagination across pages, the page-cap truncation, and the
+;; full set of result statuses (ok / empty / graphql-failed / request-failed).
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defun test-lq--page (nodes has-next &optional cursor)
+ "Build a raw issues-page response with NODES, HAS-NEXT, and CURSOR."
+ `((data (issues (nodes . ,(vconcat nodes))
+ (pageInfo (hasNextPage . ,(if has-next t :json-false))
+ (endCursor . ,(or cursor "c")))))))
+
+;;; construction
+
+(ert-deftest test-pearl-query-issues-construction ()
+ "The query targets issues(filter:) and passes the filter + default orderBy."
+ (let (captured result)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (query variables success-fn _error-fn)
+ (setq captured (list query variables))
+ (funcall success-fn (test-lq--page '(((id . "i1"))) nil)))))
+ (pearl--query-issues-async '(("assignee" ("isMe" ("eq" . t))))
+ (lambda (r) (setq result r)))
+ (should (string-match-p "issues(filter:" (car captured)))
+ (should (equal '(("assignee" ("isMe" ("eq" . t)))) (cdr (assoc "filter" (cadr captured)))))
+ (should (string= "updatedAt" (cdr (assoc "orderBy" (cadr captured)))))
+ (should (= 100 (cdr (assoc "first" (cadr captured))))))))
+
+(ert-deftest test-pearl-query-issues-no-filter-omits-variable ()
+ "With a nil filter, the filter variable is omitted (no filter applied)."
+ (let (vars)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q variables success-fn _e)
+ (setq vars variables)
+ (funcall success-fn (test-lq--page '() nil)))))
+ (pearl--query-issues-async nil #'ignore)
+ (should-not (assoc "filter" vars)))))
+
+(ert-deftest test-pearl-query-issues-order-by-override ()
+ "An explicit ORDER-BY overrides the updatedAt default."
+ (let (vars)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q variables success-fn _e)
+ (setq vars variables)
+ (funcall success-fn (test-lq--page '() nil)))))
+ (pearl--query-issues-async nil #'ignore 'createdAt)
+ (should (string= "createdAt" (cdr (assoc "orderBy" vars)))))))
+
+;;; result statuses
+
+(ert-deftest test-pearl-query-issues-single-page-ok ()
+ "A single page with issues yields an ok result carrying the raw nodes."
+ (let (result)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q _v success-fn _e)
+ (funcall success-fn (test-lq--page '(((id . "i1")) ((id . "i2"))) nil)))))
+ (pearl--query-issues-async nil (lambda (r) (setq result r)))
+ (should (eq 'ok (pearl--query-result-status result)))
+ (should (= 2 (length (pearl--query-result-issues result))))
+ (should-not (pearl--query-result-truncated-p result)))))
+
+(ert-deftest test-pearl-query-issues-empty ()
+ "A page with no nodes yields an empty result, not a failure."
+ (let (result)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q _v success-fn _e)
+ (funcall success-fn (test-lq--page '() nil)))))
+ (pearl--query-issues-async nil (lambda (r) (setq result r)))
+ (should (eq 'empty (pearl--query-result-status result))))))
+
+(ert-deftest test-pearl-query-issues-graphql-error ()
+ "A GraphQL error response surfaces as a graphql-failed result."
+ (let (result)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q _v success-fn _e)
+ (funcall success-fn '((errors . (((message . "bad filter")))))))) )
+ (pearl--query-issues-async nil (lambda (r) (setq result r)))
+ (should (eq 'graphql-failed (pearl--query-result-status result)))
+ (should (string= "bad filter" (pearl--query-result-message result))))))
+
+(ert-deftest test-pearl-query-issues-transport-error ()
+ "A transport failure (error callback) surfaces as request-failed."
+ (let (result)
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q _v _success-fn error-fn)
+ (funcall error-fn "boom" nil nil))))
+ (pearl--query-issues-async nil (lambda (r) (setq result r)))
+ (should (eq 'request-failed (pearl--query-result-status result))))))
+
+;;; pagination
+
+(ert-deftest test-pearl-query-issues-paginates ()
+ "Multiple pages accumulate; the cursor drives the next fetch."
+ (let ((result nil) (calls 0))
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q variables success-fn _e)
+ (setq calls (1+ calls))
+ (if (assoc "after" variables)
+ (funcall success-fn (test-lq--page '(((id . "i2"))) nil))
+ (funcall success-fn (test-lq--page '(((id . "i1"))) t "cur"))))))
+ (pearl--query-issues-async nil (lambda (r) (setq result r)))
+ (should (= 2 calls))
+ (should (eq 'ok (pearl--query-result-status result)))
+ (should (= 2 (length (pearl--query-result-issues result)))))))
+
+(ert-deftest test-pearl-query-issues-cap-truncates ()
+ "Hitting the page cap stops paging and marks the result truncated."
+ (let ((result nil) (calls 0)
+ (pearl-max-issue-pages 3))
+ (cl-letf (((symbol-function 'pearl--graphql-request-async)
+ (lambda (_q _v success-fn _e)
+ (setq calls (1+ calls))
+ (funcall success-fn (test-lq--page '(((id . "x"))) t "cur")))))
+ (pearl--query-issues-async nil (lambda (r) (setq result r)))
+ (should (pearl--query-result-truncated-p result))
+ (should (= 3 calls))
+ (should (= 3 (length (pearl--query-result-issues result)))))))
+
+;;; the bulk query fetches comments so the list can render them
+
+(ert-deftest test-pearl-issues-query-requests-comments ()
+ "The bulk issues query selects comments, so a populated list shows them."
+ (should (string-match-p "comments[[:space:]]*{[[:space:]]*nodes" pearl--issues-query)))
+
+(provide 'test-pearl-query)
+;;; test-pearl-query.el ends here
diff --git a/tests/test-pearl-refresh.el b/tests/test-pearl-refresh.el
new file mode 100644
index 0000000..6b8ba7b
--- /dev/null
+++ b/tests/test-pearl-refresh.el
@@ -0,0 +1,148 @@
+;;; test-pearl-refresh.el --- Tests for single-issue refresh -*- 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:
+
+;; Tests for `pearl-refresh-current-issue' and its helpers: the single
+;; issue fetch (stubbed at the HTTP boundary), the in-place subtree replace,
+;; and the per-subtree conflict guard that refuses to clobber unpushed local
+;; description edits.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+(defconst test-pearl--refresh-raw
+ '((id . "a") (identifier . "ENG-1") (title . "Refreshed Title")
+ (description . "New remote desc.") (priority . 2)
+ (url . "https://linear.app/x/ENG-1") (updatedAt . "2026-05-23T03:00:00.000Z")
+ (state (id . "s1") (name . "In Progress") (type . "started"))
+ (assignee (id . "u1") (name . "Craig") (displayName . "craig") (email . "c@x"))
+ (team (id . "t1") (key . "ENG") (name . "Engineering"))
+ (project (id . "p1") (name . "Proj"))
+ (labels (nodes . [((id . "l1") (name . "bug"))]))
+ (cycle (id . "c1") (number . 3) (name . "Cycle 3")))
+ "A raw issue node as Linear would return it for a single-issue fetch.")
+
+(defun test-pearl--clean-entry ()
+ "An issue entry whose empty body matches its stored hash (no local edit)."
+ (format "*** TODO [#B] Stale Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-IDENTIFIER: ENG-1\n:LINEAR-DESC-SHA256: %s\n:END:\n"
+ (secure-hash 'sha256 "")))
+
+;;; --fetch-issue-async
+
+(ert-deftest test-pearl-fetch-issue-returns-raw-node ()
+ "The single-issue fetch hands its callback the raw issue node."
+ (testutil-linear-with-response
+ `((data (issue . ,test-pearl--refresh-raw)))
+ (let (result)
+ (pearl--fetch-issue-async "a" (lambda (r) (setq result r)))
+ (should (string= "Refreshed Title" (cdr (assoc 'title result)))))))
+
+(ert-deftest test-pearl-fetch-issue-missing-yields-missing ()
+ "A successful response with a null issue node yields `:missing'."
+ (testutil-linear-with-response
+ '((data (issue . nil)))
+ (let ((result 'untouched))
+ (pearl--fetch-issue-async "a" (lambda (r) (setq result r)))
+ (should (eq :missing result)))))
+
+(ert-deftest test-pearl-fetch-issue-graphql-error-yields-error ()
+ "A GraphQL error response yields `:error', distinct from a missing issue."
+ (testutil-linear-with-response
+ '((errors . [((message . "boom"))]) (data . nil))
+ (let ((result 'untouched))
+ (pearl--fetch-issue-async "a" (lambda (r) (setq result r)))
+ (should (eq :error result)))))
+
+;;; refresh-current-issue
+
+(ert-deftest test-pearl-refresh-replaces-subtree-from-remote ()
+ "A clean refresh rewrites the subtree from the fetched issue."
+ (test-pearl--in-org (test-pearl--clean-entry)
+ (cl-letf (((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb test-pearl--refresh-raw))))
+ (re-search-forward "Stale Title")
+ (pearl-refresh-current-issue)
+ (goto-char (point-min))
+ (re-search-forward "^\\*\\* ")
+ ;; heading + drawer reflect the remote
+ (should (string-match-p "Refreshed Title" (thing-at-point 'line t)))
+ (should (string= "In Progress" (org-entry-get nil "LINEAR-STATE-NAME")))
+ ;; body is the remote description, and provenance matches it
+ (should (string= "New remote desc." (pearl--issue-body-at-point)))
+ (should (string= (secure-hash 'sha256 "New remote desc.")
+ (org-entry-get nil "LINEAR-DESC-SHA256"))))))
+
+(ert-deftest test-pearl-refresh-stashes-then-replaces-when-body-edited ()
+ "An unpushed local edit is stashed before the refresh overwrites it (decision 4)."
+ (let ((kill-ring nil))
+ (test-pearl--in-org
+ (format "*** TODO [#B] Stale Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: %s\n:END:\nLocal edit not yet pushed.\n"
+ (secure-hash 'sha256 ""))
+ (cl-letf (((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb test-pearl--refresh-raw))))
+ (pearl-refresh-current-issue)
+ ;; the unpushed edit was stashed before the overwrite, not lost
+ (should (string-match-p "Local edit not yet pushed\\." (current-kill 0)))
+ ;; and the refresh proceeded, replacing the subtree with the remote
+ (goto-char (point-min))
+ (should (re-search-forward "Refreshed Title" nil t))
+ (goto-char (point-min))
+ (should-not (re-search-forward "Local edit not yet pushed\\." nil t))))))
+
+(ert-deftest test-pearl-refresh-handles-fetch-error ()
+ "A fetch error leaves the subtree untouched."
+ (test-pearl--in-org (test-pearl--clean-entry)
+ (cl-letf (((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb :error))))
+ (pearl-refresh-current-issue)
+ (goto-char (point-min))
+ (should (re-search-forward "Stale Title" nil t)))))
+
+(ert-deftest test-pearl-refresh-handles-missing-issue ()
+ "A missing issue leaves the subtree untouched."
+ (test-pearl--in-org (test-pearl--clean-entry)
+ (cl-letf (((symbol-function 'pearl--fetch-issue-async)
+ (lambda (_id cb) (funcall cb :missing))))
+ (pearl-refresh-current-issue)
+ (goto-char (point-min))
+ (should (re-search-forward "Stale Title" nil t)))))
+
+(ert-deftest test-pearl-refresh-not-on-issue-errors ()
+ "Refreshing outside a Linear issue heading signals a user error."
+ (test-pearl--in-org "* Plain heading\nno id\n"
+ (should-error (pearl-refresh-current-issue) :type 'user-error)))
+
+(provide 'test-pearl-refresh)
+;;; test-pearl-refresh.el ends here
diff --git a/tests/test-pearl-resolve.el b/tests/test-pearl-resolve.el
new file mode 100644
index 0000000..d8dda7d
--- /dev/null
+++ b/tests/test-pearl-resolve.el
@@ -0,0 +1,141 @@
+;;; test-pearl-resolve.el --- Tests for name->id resolution helpers -*- 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:
+
+;; Tests for the per-team cached collection fetch (`--team-collection') and
+;; the name->id resolver (`--resolve-team-id'), which back the field commands,
+;; the ad-hoc filter, and saved queries. The HTTP boundary is stubbed; the
+;; cache is reset around each test.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--with-clean-caches (&rest body)
+ "Run BODY with the Linear caches reset and an API key set."
+ (declare (indent 0))
+ `(let ((pearl-api-key "test-key")
+ (pearl--cache-team-collections nil)
+ (pearl--cache-states nil)
+ (pearl--cache-teams nil)
+ (pearl--cache-issues nil))
+ ,@body))
+
+(defmacro test-pearl--counting-request (data counter &rest body)
+ "Run BODY with `request' stubbed to succeed with DATA, counting calls in COUNTER."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (cl-incf ,counter)
+ (let ((cb (plist-get args :success)))
+ (when cb (funcall cb :data ,data))))))
+ ,@body))
+
+;;; --team-collection
+
+(ert-deftest test-pearl-team-collection-fetches-and-caches ()
+ "A collection is fetched once and served from cache on the second call."
+ (test-pearl--with-clean-caches
+ (let ((calls 0))
+ (test-pearl--counting-request
+ '((data (team (projects (nodes . [((id . "p1") (name . "Foo"))
+ ((id . "p2") (name . "Bar"))]))))) calls
+ (let ((first (pearl--team-collection 'projects "team-1")))
+ (should (= 2 (length first)))
+ (should (string= "p1" (cdr (assoc 'id (car first)))))
+ (pearl--team-collection 'projects "team-1")
+ (should (= 1 calls)))))))
+
+(ert-deftest test-pearl-team-collection-force-refetches ()
+ "A force refresh bypasses the cache and fetches again."
+ (test-pearl--with-clean-caches
+ (let ((calls 0))
+ (test-pearl--counting-request
+ '((data (team (labels (nodes . [((id . "l1") (name . "bug"))]))))) calls
+ (pearl--team-collection 'labels "team-1")
+ (pearl--team-collection 'labels "team-1" t)
+ (should (= 2 calls))))))
+
+(ert-deftest test-pearl-team-collection-keyed-by-team ()
+ "Different teams cache separately."
+ (test-pearl--with-clean-caches
+ (let ((calls 0))
+ (test-pearl--counting-request
+ '((data (team (members (nodes . [((id . "u1") (name . "Craig"))]))))) calls
+ (pearl--team-collection 'members "team-1")
+ (pearl--team-collection 'members "team-2")
+ (should (= 2 calls))))))
+
+;;; --resolve-team-id
+
+(ert-deftest test-pearl-resolve-unique-name ()
+ "A name with one match resolves to that id."
+ (test-pearl--with-clean-caches
+ (cl-letf (((symbol-function 'pearl--team-collection)
+ (lambda (&rest _) '(((id . "p1") (name . "Foo"))
+ ((id . "p2") (name . "Bar"))))))
+ (should (string= "p1" (pearl--resolve-team-id 'projects "Foo" "team-1")))
+ ;; case-insensitive
+ (should (string= "p2" (pearl--resolve-team-id 'projects "bar" "team-1"))))))
+
+(ert-deftest test-pearl-resolve-no-match-is-nil ()
+ "A name with no match resolves to nil."
+ (test-pearl--with-clean-caches
+ (cl-letf (((symbol-function 'pearl--team-collection)
+ (lambda (&rest _) '(((id . "p1") (name . "Foo"))))))
+ (should-not (pearl--resolve-team-id 'projects "Missing" "team-1")))))
+
+(ert-deftest test-pearl-resolve-member-by-display-name-or-email ()
+ "Members match on name, displayName, or email."
+ (test-pearl--with-clean-caches
+ (cl-letf (((symbol-function 'pearl--team-collection)
+ (lambda (&rest _)
+ '(((id . "u1") (name . "Craig Jennings") (displayName . "cj")
+ (email . "c@x.com"))))))
+ (should (string= "u1" (pearl--resolve-team-id 'members "cj" "team-1")))
+ (should (string= "u1" (pearl--resolve-team-id 'members "c@x.com" "team-1"))))))
+
+(ert-deftest test-pearl-resolve-ambiguous-prompts ()
+ "When several nodes share a name, the resolver prompts and returns the choice."
+ (test-pearl--with-clean-caches
+ (cl-letf (((symbol-function 'pearl--team-collection)
+ (lambda (&rest _) '(((id . "u1") (name . "Alex") (displayName . "alex-a"))
+ ((id . "u2") (name . "Alex") (displayName . "alex-b")))))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt collection &rest _)
+ ;; pick the entry whose id is u2
+ (car (rassoc "u2" collection)))))
+ (should (string= "u2" (pearl--resolve-team-id 'members "Alex" "team-1"))))))
+
+;;; clear-cache
+
+(ert-deftest test-pearl-clear-cache-resets ()
+ "Clearing the cache empties the collection and per-team caches."
+ (let ((pearl--cache-team-collections '(((projects . "t") . x)))
+ (pearl--cache-states '(("t" . y)))
+ (pearl--cache-teams '(z)))
+ (pearl-clear-cache)
+ (should-not pearl--cache-team-collections)
+ (should-not pearl--cache-states)
+ (should-not pearl--cache-teams)))
+
+(provide 'test-pearl-resolve)
+;;; test-pearl-resolve.el ends here
diff --git a/tests/test-pearl-result.el b/tests/test-pearl-result.el
new file mode 100644
index 0000000..606c3b0
--- /dev/null
+++ b/tests/test-pearl-result.el
@@ -0,0 +1,98 @@
+;;; test-pearl-result.el --- Tests for the query result shape -*- 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:
+
+;; Tests for the tagged query-result shape and `--classify-response': the five
+;; statuses (ok / empty / invalid-filter / request-failed / graphql-failed),
+;; the ok/error predicates, the GraphQL error-message extraction (vector and
+;; list forms), and the truncated flag. All pure -- no network.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; classify-response
+
+(ert-deftest test-pearl-result-classify-nil-is-request-failed ()
+ "A nil response is a transport failure."
+ (let ((r (pearl--classify-response nil)))
+ (should (eq 'request-failed (pearl--query-result-status r)))
+ (should (pearl--query-result-error-p r))
+ (should (stringp (pearl--query-result-message r)))))
+
+(ert-deftest test-pearl-result-classify-errors-is-graphql-failed ()
+ "A response carrying errors is a GraphQL failure with the message extracted."
+ (let ((r (pearl--classify-response '((errors . (((message . "bad filter"))))))))
+ (should (eq 'graphql-failed (pearl--query-result-status r)))
+ (should (string= "bad filter" (pearl--query-result-message r)))))
+
+(ert-deftest test-pearl-result-classify-errors-vector-form ()
+ "Errors as a vector (the live API shape) still yield the first message."
+ (let ((r (pearl--classify-response '((errors . [((message . "nope"))])))))
+ (should (eq 'graphql-failed (pearl--query-result-status r)))
+ (should (string= "nope" (pearl--query-result-message r)))))
+
+(ert-deftest test-pearl-result-classify-data-with-issues-is-ok ()
+ "A data response with issues is ok and carries the issues."
+ (let ((r (pearl--classify-response '((data (issues))) '(i1 i2))))
+ (should (eq 'ok (pearl--query-result-status r)))
+ (should (pearl--query-result-ok-p r))
+ (should (equal '(i1 i2) (pearl--query-result-issues r)))))
+
+(ert-deftest test-pearl-result-classify-data-without-issues-is-empty ()
+ "A data response with no issues is empty, not a failure."
+ (let ((r (pearl--classify-response '((data (issues))) '())))
+ (should (eq 'empty (pearl--query-result-status r)))
+ (should (pearl--query-result-ok-p r))
+ (should-not (pearl--query-result-error-p r))))
+
+(ert-deftest test-pearl-result-classify-no-data-no-errors-is-request-failed ()
+ "A response with neither data nor errors is treated as malformed/transport."
+ (let ((r (pearl--classify-response '((extensions . nil)))))
+ (should (eq 'request-failed (pearl--query-result-status r)))))
+
+(ert-deftest test-pearl-result-classify-carries-truncated-flag ()
+ "The truncated flag is carried through on a successful result."
+ (let ((r (pearl--classify-response '((data (issues))) '(i1) t)))
+ (should (pearl--query-result-truncated-p r))))
+
+(ert-deftest test-pearl-result-classify-ok-not-truncated-by-default ()
+ "Without the truncated argument the flag is nil."
+ (let ((r (pearl--classify-response '((data (issues))) '(i1))))
+ (should-not (pearl--query-result-truncated-p r))))
+
+;;; invalid-filter
+
+(ert-deftest test-pearl-result-invalid-filter ()
+ "An invalid-filter result is an error carrying its message."
+ (let ((r (pearl--invalid-filter-result "bad :priority")))
+ (should (eq 'invalid-filter (pearl--query-result-status r)))
+ (should (pearl--query-result-error-p r))
+ (should-not (pearl--query-result-ok-p r))
+ (should (string= "bad :priority" (pearl--query-result-message r)))))
+
+;;; error-message extraction
+
+(ert-deftest test-pearl-result-graphql-error-message-nil-when-absent ()
+ "With no errors key, the error-message extractor returns nil."
+ (should (null (pearl--graphql-error-message '((data (issues)))))))
+
+(provide 'test-pearl-result)
+;;; test-pearl-result.el ends here
diff --git a/tests/test-pearl-saved.el b/tests/test-pearl-saved.el
new file mode 100644
index 0000000..63a3f8c
--- /dev/null
+++ b/tests/test-pearl-saved.el
@@ -0,0 +1,112 @@
+;;; test-pearl-saved.el --- Tests for saved queries + sort -*- 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:
+
+;; Tests for local saved queries and the sort layer: the client-side
+;; `--sort-issues', the sort->orderBy mapping, and `pearl-run-saved-query'
+;; threading the filter, source, and order through.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+;;; --sort-issues
+
+(defconst test-pearl--sort-sample
+ '((:title "banana" :priority 2 :updated-at "2026-05-23T10:00:00.000Z")
+ (:title "apple" :priority 1 :updated-at "2026-05-23T12:00:00.000Z")
+ (:title "cherry" :priority 3 :updated-at "2026-05-23T08:00:00.000Z"))
+ "Three issues for exercising the client-side sort.")
+
+(ert-deftest test-pearl-sort-issues-nil-sort-unchanged ()
+ "With no sort key, the issues are returned in their original order."
+ (should (equal test-pearl--sort-sample
+ (pearl--sort-issues test-pearl--sort-sample nil nil))))
+
+(ert-deftest test-pearl-sort-issues-title-asc ()
+ "Sorting by title ascending orders alphabetically."
+ (let ((out (pearl--sort-issues test-pearl--sort-sample 'title 'asc)))
+ (should (equal '("apple" "banana" "cherry")
+ (mapcar (lambda (i) (plist-get i :title)) out)))))
+
+(ert-deftest test-pearl-sort-issues-updated-desc-default ()
+ "Sorting by updated, descending, puts the most recent first."
+ (let ((out (pearl--sort-issues test-pearl--sort-sample 'updated 'desc)))
+ (should (string= "apple" (plist-get (car out) :title)))
+ (should (string= "cherry" (plist-get (car (last out)) :title)))))
+
+(ert-deftest test-pearl-sort-issues-priority-asc ()
+ "Sorting by priority ascending orders by the numeric value."
+ (let ((out (pearl--sort-issues test-pearl--sort-sample 'priority 'asc)))
+ (should (equal '(1 2 3) (mapcar (lambda (i) (plist-get i :priority)) out)))))
+
+;;; --sort->order-by
+
+(ert-deftest test-pearl-sort-to-order-by ()
+ "Updated/created map to the server orderBy; everything else defaults to updatedAt."
+ (should (eq 'updatedAt (pearl--sort->order-by 'updated)))
+ (should (eq 'createdAt (pearl--sort->order-by 'created)))
+ (should (eq 'updatedAt (pearl--sort->order-by 'title))))
+
+;;; run-saved-query
+
+(ert-deftest test-pearl-run-saved-query-threads-filter-and-source ()
+ "Running a saved query compiles its filter and renders with a sorted source."
+ (let ((pearl-saved-queries
+ '(("My bugs" :filter (:labels ("bug") :open t) :sort priority :order asc)))
+ (built nil) (rendered-source nil) (order nil))
+ (cl-letf (((symbol-function 'pearl--build-issue-filter)
+ (lambda (plist) (setq built plist) '((compiled . t))))
+ ((symbol-function 'pearl--query-issues-async)
+ (lambda (_filter cb &optional ord) (setq order ord)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--render-query-result)
+ (lambda (_result source) (setq rendered-source source))))
+ (pearl-run-saved-query "My bugs")
+ (should (equal '(:labels ("bug") :open t) built))
+ (should (eq 'priority (plist-get rendered-source :sort)))
+ (should (eq 'asc (plist-get rendered-source :order)))
+ (should (eq 'filter (plist-get rendered-source :type)))
+ (should (eq 'updatedAt order)))))
+
+(ert-deftest test-pearl-run-saved-query-unknown-errors ()
+ "An unknown saved-query name signals a user error."
+ (let ((pearl-saved-queries '(("Known" :filter (:open t)))))
+ (should-error (pearl-run-saved-query "Missing") :type 'user-error)))
+
+;;; render applies the source sort
+
+(ert-deftest test-pearl-render-query-result-sorts-by-source ()
+ "The render boundary sorts issues by the source's sort/order before writing."
+ (let ((written nil)
+ (source '(:type filter :name "By title" :filter nil :sort title :order asc)))
+ (cl-letf (((symbol-function 'pearl--normalize-issue) #'identity)
+ ((symbol-function 'pearl--update-org-from-issues)
+ (lambda (issues &optional _s _t) (setq written issues))))
+ (pearl--render-query-result
+ (pearl--make-query-result
+ 'ok :issues '((:title "banana") (:title "apple")))
+ source)
+ (should (equal '("apple" "banana")
+ (mapcar (lambda (i) (plist-get i :title)) written))))))
+
+(provide 'test-pearl-saved)
+;;; test-pearl-saved.el ends here
diff --git a/tests/test-pearl-smoke.el b/tests/test-pearl-smoke.el
new file mode 100644
index 0000000..53c4d06
--- /dev/null
+++ b/tests/test-pearl-smoke.el
@@ -0,0 +1,37 @@
+;;; test-pearl-smoke.el --- Harness smoke test for pearl -*- 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:
+
+;; Plumbing-proof smoke test: confirms the harness loads the package and
+;; defines its public commands. Behavior coverage lives in the per-area
+;; test files (test-pearl-mapping.el, -format.el, ...).
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+(ert-deftest test-pearl-smoke-feature-loaded ()
+ "The package source loads and defines its public commands."
+ (should (featurep 'pearl))
+ (should (fboundp 'pearl-list-issues))
+ (should (fboundp 'pearl-new-issue)))
+
+(provide 'test-pearl-smoke)
+;;; test-pearl-smoke.el ends here
diff --git a/tests/test-pearl-states.el b/tests/test-pearl-states.el
new file mode 100644
index 0000000..713cb78
--- /dev/null
+++ b/tests/test-pearl-states.el
@@ -0,0 +1,134 @@
+;;; test-pearl-states.el --- Tests for pearl state management -*- 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:
+
+;; Tests for workflow-state fetching, state name -> id resolution, and the
+;; two state-update entry points. Covers the guard that skips the mutation
+;; when the state name doesn't resolve, so no request fires with a null id.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+;;; pearl-get-states-async
+
+(ert-deftest test-pearl-get-states-async-parses-nodes ()
+ "Workflow states are unwrapped and passed to the callback."
+ (let ((got nil))
+ (testutil-linear-with-response
+ '((data (team (states (nodes . (((id . "s1") (name . "Todo"))
+ ((id . "s2") (name . "Done"))))))))
+ (pearl-get-states-async "team-1" (lambda (states) (setq got states))))
+ (should (= 2 (length got)))
+ (should (string-equal "s1" (cdr (assoc 'id (car got)))))))
+
+;;; pearl--get-state-id-by-name
+
+(ert-deftest test-pearl-get-state-id-by-name-found ()
+ "A state whose name matches (case-insensitively) resolves to its id."
+ (let ((pearl--cache-states nil))
+ (testutil-linear-with-response
+ '((data (team (states (nodes . (((id . "s1") (name . "Todo"))
+ ((id . "s2") (name . "Done"))))))))
+ (should (string-equal "s2" (pearl--get-state-id-by-name "done" "team-1"))))))
+
+(ert-deftest test-pearl-get-state-id-by-name-not-found ()
+ "A state name absent from the team resolves to nil."
+ (let ((pearl--cache-states nil))
+ (testutil-linear-with-response
+ '((data (team (states (nodes . (((id . "s1") (name . "Todo"))))))))
+ (should (null (pearl--get-state-id-by-name "Archived" "team-1"))))))
+
+(ert-deftest test-pearl-state-lookup-caches-per-team ()
+ "A second lookup for the same team is served from cache, no new request."
+ (let ((pearl-api-key "test-key")
+ (pearl--cache-states nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (setq calls (1+ calls))
+ (funcall (plist-get args :success)
+ :data '((data (team (states (nodes . (((id . "s1") (name . "Todo"))
+ ((id . "s2") (name . "Done"))))))))))))
+ (should (string-equal "s2" (pearl--get-state-id-by-name "Done" "team-1")))
+ (should (string-equal "s1" (pearl--get-state-id-by-name "Todo" "team-1")))
+ (should (= 1 calls)))))
+
+;;; pearl-update-issue-state (sync) -- nil-state-id guard
+
+(ert-deftest test-pearl-update-issue-state-nil-state-id-skips-request ()
+ "When the state name doesn't resolve, no mutation request is fired."
+ (let ((requested nil)
+ (pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'pearl--get-state-id-by-name) (lambda (_s _t) nil))
+ ((symbol-function 'request) (lambda (&rest _) (setq requested t))))
+ (pearl-update-issue-state "i-1" "Nonexistent" "team-1")
+ (should-not requested))))
+
+(ert-deftest test-pearl-update-issue-state-resolved-fires-request ()
+ "When the state resolves, the mutation request is fired."
+ (let ((requested nil)
+ (pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'pearl--get-state-id-by-name) (lambda (_s _t) "s2"))
+ ((symbol-function 'request)
+ (lambda (_url &rest args)
+ (setq requested t)
+ (let ((cb (plist-get args :success)))
+ (when cb (funcall cb :data '((data (issueUpdate (success . t))))))))))
+ (pearl-update-issue-state "i-1" "Done" "team-1")
+ (should requested))))
+
+;;; pearl--update-issue-state-async -- nil-state-id guard
+
+(ert-deftest test-pearl-update-issue-state-async-nil-state-id-skips-request ()
+ "The async update also short-circuits when the state name doesn't resolve."
+ (let ((requested nil)
+ (pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'pearl--get-state-id-by-name) (lambda (_s _t) nil))
+ ((symbol-function 'request) (lambda (&rest _) (setq requested t))))
+ (pearl--update-issue-state-async "i-1" "Nonexistent" "team-1")
+ (should-not requested))))
+
+(ert-deftest test-pearl-update-issue-state-async-success-runs ()
+ "The async success handler runs on a successful update."
+ (let ((pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'pearl--get-state-id-by-name) (lambda (_s _t) "s2"))
+ ((symbol-function 'request)
+ (lambda (_url &rest args)
+ (funcall (plist-get args :success)
+ :data '((data (issueUpdate (success . t))))))))
+ (should (progn (pearl--update-issue-state-async "i-1" "Done" "team-1") t)))))
+
+(ert-deftest test-pearl-update-issue-state-async-error-runs ()
+ "The async error handler runs on a transport error."
+ (let ((pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'pearl--get-state-id-by-name) (lambda (_s _t) "s2"))
+ ((symbol-function 'request)
+ (lambda (_url &rest args)
+ (funcall (plist-get args :error)
+ :error-thrown "boom"
+ :response (make-request-response :status-code 500)
+ :data nil))))
+ (should (progn (pearl--update-issue-state-async "i-1" "Done" "team-1") t)))))
+
+(provide 'test-pearl-states)
+;;; test-pearl-states.el ends here
diff --git a/tests/test-pearl-surface.el b/tests/test-pearl-surface.el
new file mode 100644
index 0000000..4c2b7e5
--- /dev/null
+++ b/tests/test-pearl-surface.el
@@ -0,0 +1,81 @@
+;;; test-pearl-surface.el --- Tests for surfacing the active buffer -*- 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:
+
+;; Tests for `pearl--surface-buffer': bring the active org buffer to a window
+;; after a command updates it, gated on `pearl-surface-buffer', focus-following
+;; gated on `pearl-surface-select-window', and skipped when the buffer is dead
+;; or already visible.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+(ert-deftest test-pearl-surface-buffer-disabled-is-noop ()
+ "With `pearl-surface-buffer' nil, nothing is displayed."
+ (let ((pearl-surface-buffer nil) (shown nil))
+ (cl-letf (((symbol-function 'display-buffer) (lambda (&rest _) (setq shown t)))
+ ((symbol-function 'pop-to-buffer) (lambda (&rest _) (setq shown t))))
+ (with-temp-buffer
+ (pearl--surface-buffer (current-buffer))
+ (should-not shown)))))
+
+(ert-deftest test-pearl-surface-buffer-shows-when-buried ()
+ "A live, un-displayed buffer is shown via `display-buffer' by default."
+ (let ((pearl-surface-buffer t) (pearl-surface-select-window nil) (shown nil))
+ (cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) nil))
+ ((symbol-function 'display-buffer) (lambda (b &rest _) (setq shown b)))
+ ((symbol-function 'pop-to-buffer) (lambda (&rest _) (setq shown 'pop))))
+ (with-temp-buffer
+ (pearl--surface-buffer (current-buffer))
+ (should (eq shown (current-buffer)))))))
+
+(ert-deftest test-pearl-surface-buffer-skips-when-already-shown ()
+ "A buffer already visible in some window is left alone."
+ (let ((pearl-surface-buffer t) (shown nil))
+ (cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) 'a-window))
+ ((symbol-function 'display-buffer) (lambda (&rest _) (setq shown t)))
+ ((symbol-function 'pop-to-buffer) (lambda (&rest _) (setq shown t))))
+ (with-temp-buffer
+ (pearl--surface-buffer (current-buffer))
+ (should-not shown)))))
+
+(ert-deftest test-pearl-surface-buffer-select-window-uses-pop-to-buffer ()
+ "With `pearl-surface-select-window' non-nil, focus follows via `pop-to-buffer'."
+ (let ((pearl-surface-buffer t) (pearl-surface-select-window t) (how nil))
+ (cl-letf (((symbol-function 'get-buffer-window) (lambda (&rest _) nil))
+ ((symbol-function 'display-buffer) (lambda (&rest _) (setq how 'display)))
+ ((symbol-function 'pop-to-buffer) (lambda (&rest _) (setq how 'pop))))
+ (with-temp-buffer
+ (pearl--surface-buffer (current-buffer))
+ (should (eq how 'pop))))))
+
+(ert-deftest test-pearl-surface-buffer-dead-buffer-is-noop ()
+ "A killed buffer is never surfaced."
+ (let ((pearl-surface-buffer t) (shown nil) (buf (generate-new-buffer "x")))
+ (kill-buffer buf)
+ (cl-letf (((symbol-function 'display-buffer) (lambda (&rest _) (setq shown t)))
+ ((symbol-function 'pop-to-buffer) (lambda (&rest _) (setq shown t))))
+ (pearl--surface-buffer buf)
+ (should-not shown))))
+
+(provide 'test-pearl-surface)
+;;; test-pearl-surface.el ends here
diff --git a/tests/test-pearl-sync-hooks.el b/tests/test-pearl-sync-hooks.el
new file mode 100644
index 0000000..05864e7
--- /dev/null
+++ b/tests/test-pearl-sync-hooks.el
@@ -0,0 +1,175 @@
+;;; test-pearl-sync-hooks.el --- Tests for pearl org sync hooks -*- 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:
+
+;; Tests for the org sync hook wiring: enable/disable add and remove
+;; buffer-local hooks; the after-save hook only fires for linear.org buffers;
+;; and per-heading sync degrades gracefully when point is before any heading.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+;;; enable / disable
+
+(ert-deftest test-pearl-enable-org-sync-adds-buffer-local-hooks ()
+ "Enabling sync adds both hook functions buffer-locally."
+ (with-temp-buffer
+ (pearl-enable-org-sync)
+ (should (memq 'pearl-org-hook-function after-save-hook))
+ (should (memq 'pearl-sync-org-to-linear org-after-todo-state-change-hook))))
+
+(ert-deftest test-pearl-disable-org-sync-removes-hooks ()
+ "Disabling sync removes both hook functions."
+ (with-temp-buffer
+ (pearl-enable-org-sync)
+ (pearl-disable-org-sync)
+ (should-not (memq 'pearl-org-hook-function after-save-hook))
+ (should-not (memq 'pearl-sync-org-to-linear org-after-todo-state-change-hook))))
+
+;;; org-hook-function buffer guard
+
+(ert-deftest test-pearl-org-hook-function-skips-other-buffer ()
+ "The after-save hook does nothing in a buffer that isn't the configured file."
+ (let ((called nil)
+ (pearl-org-file-path "/tmp/linear.org"))
+ (cl-letf (((symbol-function 'pearl-sync-org-to-linear) (lambda () (setq called t))))
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/scratch.org")
+ (pearl-org-hook-function)
+ (should-not called)))))
+
+(ert-deftest test-pearl-org-hook-function-syncs-configured-buffer ()
+ "The after-save hook syncs when the buffer visits `pearl-org-file-path'."
+ (let ((called nil)
+ (pearl-org-file-path "/tmp/linear.org"))
+ (cl-letf (((symbol-function 'pearl-sync-org-to-linear) (lambda () (setq called t))))
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/linear.org")
+ (pearl-org-hook-function)
+ (should called)))))
+
+(ert-deftest test-pearl-org-hook-function-honors-custom-path ()
+ "A non-default `pearl-org-file-path' is what the hook matches on.
+Regression: the hook used to hardcode a \"linear.org$\" regex, so a buffer
+named linear.org fired even when the configured file was elsewhere, and a
+custom-named configured file never fired."
+ (let ((called nil)
+ (pearl-org-file-path "/tmp/my-linear-issues.org"))
+ (cl-letf (((symbol-function 'pearl-sync-org-to-linear) (lambda () (setq called t))))
+ ;; A buffer literally named linear.org must NOT fire when the configured
+ ;; file is something else.
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/linear.org")
+ (pearl-org-hook-function)
+ (should-not called))
+ ;; The configured custom-named file DOES fire.
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/my-linear-issues.org")
+ (pearl-org-hook-function)
+ (should called)))))
+
+(ert-deftest test-pearl-org-hook-function-matches-through-symlink ()
+ "A configured path and a visited symlink to the same file match via truename.
+The hook resolves both sides with `file-truename', so a symlink to the
+configured file still syncs -- this guards the choice of truename over a raw
+string compare."
+ (let ((real (make-temp-file "linear-real-" nil ".org"))
+ (link (make-temp-file "linear-link-" nil ".org"))
+ (called nil))
+ (unwind-protect
+ (progn
+ (delete-file link)
+ (make-symbolic-link real link)
+ (let ((pearl-org-file-path real))
+ (cl-letf (((symbol-function 'pearl-sync-org-to-linear)
+ (lambda () (setq called t))))
+ (with-temp-buffer
+ (setq buffer-file-name link)
+ (pearl-org-hook-function)
+ (should called)))))
+ (when (file-exists-p link) (delete-file link))
+ (when (file-exists-p real) (delete-file real)))))
+
+(ert-deftest test-pearl-org-hook-function-nil-path-no-op ()
+ "With `pearl-org-file-path' nil, the hook is a no-op and does not error."
+ (let ((called nil)
+ (pearl-org-file-path nil))
+ (cl-letf (((symbol-function 'pearl-sync-org-to-linear) (lambda () (setq called t))))
+ (with-temp-buffer
+ (setq buffer-file-name "/tmp/linear.org")
+ (should (progn (pearl-org-hook-function) t))
+ (should-not called)))))
+
+;;; sync-current-heading-to-linear
+
+(ert-deftest test-pearl-sync-current-heading-before-first-heading-no-error ()
+ "Syncing with point before the first heading degrades gracefully.
+
+Regression: `org-back-to-heading' signals \"before first heading\" in the
+preamble, which must not propagate out of the sync entry point."
+ (cl-letf (((symbol-function 'pearl--process-heading-at-point) (lambda () nil)))
+ (with-temp-buffer
+ (insert "#+TITLE: x\n\npreamble line\n")
+ (org-mode)
+ (goto-char (point-min))
+ (should (progn (pearl-sync-current-heading-to-linear) t)))))
+
+(ert-deftest test-pearl-sync-current-heading-processes-on-heading ()
+ "Syncing from within an entry processes that heading."
+ (let ((called nil))
+ (cl-letf (((symbol-function 'pearl--process-heading-at-point)
+ (lambda () (setq called t))))
+ (with-temp-buffer
+ (insert "* Top\n*** TODO x\n")
+ (org-mode)
+ (goto-char (point-max))
+ (pearl-sync-current-heading-to-linear)
+ (should called)))))
+
+;;; sync-org-to-linear dispatcher
+
+(ert-deftest test-pearl-sync-org-to-linear-org-todo-syncs-current-heading ()
+ "When invoked from `org-todo', only the current heading is synced."
+ (let ((called nil)
+ (this-command 'org-todo))
+ (cl-letf (((symbol-function 'pearl-sync-current-heading-to-linear)
+ (lambda () (setq called t))))
+ (pearl-sync-org-to-linear)
+ (should called))))
+
+(ert-deftest test-pearl-sync-org-to-linear-otherwise-scans-whole-file ()
+ "Outside `org-todo', every matching heading in the buffer is processed."
+ (let ((count 0)
+ (this-command 'some-other-command)
+ (pearl-state-to-todo-mapping '(("Todo" . "TODO") ("Done" . "DONE")))
+ (pearl-todo-states-pattern nil)
+ (pearl--todo-states-pattern-source nil))
+ (cl-letf (((symbol-function 'pearl--process-heading-at-point)
+ (lambda () (setq count (1+ count)))))
+ (with-temp-buffer
+ (insert "*** TODO a\n*** DONE b\n")
+ (org-mode)
+ (pearl-sync-org-to-linear)
+ (should (= 2 count))))))
+
+(provide 'test-pearl-sync-hooks)
+;;; test-pearl-sync-hooks.el ends here
diff --git a/tests/test-pearl-sync-wrappers.el b/tests/test-pearl-sync-wrappers.el
new file mode 100644
index 0000000..a5c9c70
--- /dev/null
+++ b/tests/test-pearl-sync-wrappers.el
@@ -0,0 +1,83 @@
+;;; test-pearl-sync-wrappers.el --- Tests for sync wrappers + pagination -*- 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:
+
+;; Tests for the synchronous busy-wait wrappers (teams, states, create) and
+;; the completing-read-driven selectors. The HTTP boundary is stubbed;
+;; `completing-read' is stubbed for the selectors.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+;;; Synchronous wrappers
+
+(ert-deftest test-pearl-get-teams-sync-returns-teams ()
+ "The sync teams wrapper returns the async result."
+ (testutil-linear-with-response
+ '((data (teams (nodes . (((id . "t1") (name . "Eng")))))))
+ (should (= 1 (length (pearl-get-teams))))))
+
+(ert-deftest test-pearl-get-states-sync-returns-states ()
+ "The sync states wrapper returns the async result."
+ (testutil-linear-with-response
+ '((data (team (states (nodes . (((id . "s1") (name . "Todo")))))) ))
+ (should (= 1 (length (pearl-get-states "team-1"))))))
+
+(ert-deftest test-pearl-create-issue-sync-returns-issue ()
+ "The sync create wrapper returns the created issue node."
+ (testutil-linear-with-response
+ '((data (issueCreate (success . t) (issue (id . "i1") (identifier . "ENG-1") (title . "T")))))
+ (should (string-equal "ENG-1" (cdr (assoc 'identifier (pearl-create-issue "T" "" "team")))))))
+
+(ert-deftest test-pearl-sync-wrapper-times-out-without-callback ()
+ "A sync wrapper returns nil instead of hanging when no callback fires."
+ (let ((pearl-request-timeout 0.3)
+ (pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'request) (lambda (&rest _) nil)))
+ (should (null (pearl-get-teams))))))
+
+;;; Selectors (completing-read stubbed)
+
+(ert-deftest test-pearl-select-team-returns-chosen-team ()
+ "Selecting a team returns the matching team alist."
+ (let ((pearl--cache-teams '(((id . "t1") (name . "Eng"))
+ ((id . "t2") (name . "Ops")))))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "Ops")))
+ (should (string-equal "t2" (cdr (assoc 'id (pearl-select-team))))))))
+
+(ert-deftest test-pearl-select-project-returns-chosen-project ()
+ "Selecting a project returns the matching project alist."
+ (cl-letf (((symbol-function 'pearl-get-projects)
+ (lambda (_tid) '(((id . "p1") (name . "Platform")))))
+ ((symbol-function 'completing-read) (lambda (&rest _) "Platform")))
+ (should (string-equal "p1" (cdr (assoc 'id (pearl-select-project "team-1")))))))
+
+(ert-deftest test-pearl-select-project-none-returns-nil ()
+ "Choosing None returns nil."
+ (cl-letf (((symbol-function 'pearl-get-projects)
+ (lambda (_tid) '(((id . "p1") (name . "Platform")))))
+ ((symbol-function 'completing-read) (lambda (&rest _) "None")))
+ (should (null (pearl-select-project "team-1")))))
+
+(provide 'test-pearl-sync-wrappers)
+;;; test-pearl-sync-wrappers.el ends here
diff --git a/tests/test-pearl-sync.el b/tests/test-pearl-sync.el
new file mode 100644
index 0000000..6127914
--- /dev/null
+++ b/tests/test-pearl-sync.el
@@ -0,0 +1,206 @@
+;;; test-pearl-sync.el --- Tests for description sync-back -*- 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:
+
+;; Tests for the explicit description sync-back: the pure conflict gate
+;; (`pearl--sync-decision'), the org body extractor
+;; (`pearl--issue-body-at-point'), the two network helpers (fetch the
+;; remote description, push an updated description -- both stubbed at the HTTP
+;; boundary), and the orchestrating command `pearl-sync-current-issue'
+;; across its no-op / clean-push / conflict branches.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT at point-min."
+ (declare (indent 1))
+ `(let ((org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; --sync-decision (pure conflict gate)
+
+(ert-deftest test-pearl-sync-decision-noop-no-local-edit ()
+ "Local matches the stored baseline: no local edit, no push."
+ (let ((md "the description"))
+ (should (eq :noop (pearl--sync-decision
+ md (secure-hash 'sha256 md) "remote moved on")))))
+
+(ert-deftest test-pearl-sync-decision-push-remote-unchanged ()
+ "Local edited and remote still equals the baseline: clean push."
+ (should (eq :push (pearl--sync-decision
+ "edited locally" (secure-hash 'sha256 "baseline") "baseline"))))
+
+(ert-deftest test-pearl-sync-decision-conflict-both-changed ()
+ "Local and remote both moved away from the baseline, differently: conflict."
+ (should (eq :conflict (pearl--sync-decision
+ "edited local" (secure-hash 'sha256 "baseline") "edited remote"))))
+
+(ert-deftest test-pearl-sync-decision-noop-converged ()
+ "Local and remote ended up identical though both differ from baseline: no push."
+ (should (eq :noop (pearl--sync-decision
+ "same new text" (secure-hash 'sha256 "baseline") "same new text"))))
+
+;;; --issue-body-at-point (org body extractor)
+
+(ert-deftest test-pearl-issue-body-after-drawer ()
+ "The body is the text after the drawer, trimmed."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nThe body line.\nSecond line.\n"
+ (re-search-forward "Title")
+ (should (string= "The body line.\nSecond line."
+ (pearl--issue-body-at-point)))))
+
+(ert-deftest test-pearl-issue-body-empty ()
+ "An entry with no body yields the empty string."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (should (string= "" (pearl--issue-body-at-point)))))
+
+(ert-deftest test-pearl-issue-body-stops-before-comments ()
+ "The description body stops before a child Comments subtree."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nDesc body.\n**** Comments\n***** bob -- ts\nhi\n"
+ (should (string= "Desc body." (pearl--issue-body-at-point)))))
+
+(ert-deftest test-pearl-issue-body-from-inside-body ()
+ "Extraction works with point already inside the body."
+ (test-pearl--in-org
+ "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\nDesc body.\nmore.\n"
+ (goto-char (point-max))
+ (should (string= "Desc body.\nmore." (pearl--issue-body-at-point)))))
+
+;;; network helpers (stubbed at the HTTP boundary)
+
+(ert-deftest test-pearl-fetch-issue-description-parses-payload ()
+ "The fetch helper returns the remote description and timestamp as a plist."
+ (testutil-linear-with-response
+ '((data (issue (description . "remote markdown")
+ (updatedAt . "2026-05-23T00:00:00.000Z"))))
+ (let (result)
+ (pearl--fetch-issue-description-async
+ "id-1" (lambda (r) (setq result r)))
+ (should (string= "remote markdown" (plist-get result :description)))
+ (should (string= "2026-05-23T00:00:00.000Z" (plist-get result :updated-at))))))
+
+(ert-deftest test-pearl-update-issue-description-success ()
+ "A successful issueUpdate reports success and carries the new timestamp."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . t)
+ (issue (id . "id-1")
+ (updatedAt . "2026-05-23T01:00:00.000Z")))))
+ (let (result)
+ (pearl--update-issue-description-async
+ "id-1" "new body" (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success)))
+ (should (string= "2026-05-23T01:00:00.000Z" (plist-get result :updated-at))))))
+
+(ert-deftest test-pearl-update-issue-description-soft-fail ()
+ "A non-success issueUpdate reports failure rather than erroring."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . :json-false) (issue . nil))))
+ (let ((called nil) result)
+ (pearl--update-issue-description-async
+ "id-1" "new body" (lambda (r) (setq called t result r)))
+ (should called)
+ (should-not (plist-get result :success)))))
+
+;;; pearl-sync-current-issue (orchestration branches)
+
+(defmacro test-pearl--with-sync-stubs (fetch-remote update-spy &rest body)
+ "Run BODY with the two network helpers stubbed.
+FETCH-REMOTE is the plist the fetch helper hands its callback. UPDATE-SPY is
+a symbol bound to a list that captures the markdown passed to the update
+helper (nil when never called); the update helper reports success."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'pearl--fetch-issue-description-async)
+ (lambda (_id cb) (funcall cb ,fetch-remote)))
+ ((symbol-function 'pearl--update-issue-description-async)
+ (lambda (_id md cb)
+ (push md ,update-spy)
+ (funcall cb '(:success t :updated-at "2026-05-23T02:00:00.000Z")))))
+ ,@body))
+
+(ert-deftest test-pearl-sync-current-issue-noop-skips-network ()
+ "No local edit: neither the fetch nor the update helper is called."
+ (let ((md "Hello **world** and `code`.")
+ (fetched nil) (updates nil))
+ (test-pearl--in-org
+ (format "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: %s\n:END:\n%s\n"
+ (secure-hash 'sha256 "Hello **world** and `code`.")
+ (pearl--md-to-org "Hello **world** and `code`."))
+ (ignore md)
+ (cl-letf (((symbol-function 'pearl--fetch-issue-description-async)
+ (lambda (&rest _) (setq fetched t)))
+ ((symbol-function 'pearl--update-issue-description-async)
+ (lambda (&rest _) (push 'called updates))))
+ (pearl-sync-current-issue)
+ (should-not fetched)
+ (should-not updates)))))
+
+(ert-deftest test-pearl-sync-current-issue-push-updates-provenance ()
+ "Local edit + remote unchanged: push the rendered markdown, update the hash."
+ (let ((updates nil))
+ (test-pearl--in-org
+ (format "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: %s\n:LINEAR-DESC-UPDATED-AT: old\n:END:\nEdited body now.\n"
+ (secure-hash 'sha256 "baseline remote"))
+ (test-pearl--with-sync-stubs '(:description "baseline remote" :updated-at "t0") updates
+ (pearl-sync-current-issue)
+ ;; the pushed markdown is the org body rendered back to md
+ (should (equal (list (pearl--org-to-md "Edited body now."))
+ updates))
+ ;; provenance advanced to the pushed content + the push timestamp
+ (should (string= (secure-hash 'sha256 (pearl--org-to-md "Edited body now."))
+ (org-entry-get nil "LINEAR-DESC-SHA256")))
+ (should (string= "2026-05-23T02:00:00.000Z"
+ (org-entry-get nil "LINEAR-DESC-UPDATED-AT")))))))
+
+(ert-deftest test-pearl-sync-current-issue-conflict-refuses ()
+ "Local edit + remote also changed: refuse, do not push, keep provenance."
+ (let ((updates nil)
+ (stored (secure-hash 'sha256 "baseline remote")))
+ (test-pearl--in-org
+ (format "*** TODO ENG-1 Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-DESC-SHA256: %s\n:END:\nEdited body now.\n"
+ stored)
+ (test-pearl--with-sync-stubs '(:description "remote changed too" :updated-at "t1") updates
+ ;; On conflict the command now prompts; cancel preserves the old
+ ;; refuse-and-keep-provenance behavior.
+ (cl-letf (((symbol-function 'pearl--read-conflict-resolution)
+ (lambda (_label) 'cancel)))
+ (pearl-sync-current-issue)
+ (should-not updates)
+ ;; provenance untouched when the conflict is cancelled
+ (should (string= stored (org-entry-get nil "LINEAR-DESC-SHA256"))))))))
+
+(ert-deftest test-pearl-sync-current-issue-not-on-issue-errors ()
+ "Running the command outside a Linear issue heading signals a user error."
+ (test-pearl--in-org
+ "* Just a plain heading\nno linear id here\n"
+ (should-error (pearl-sync-current-issue) :type 'user-error)))
+
+(provide 'test-pearl-sync)
+;;; test-pearl-sync.el ends here
diff --git a/tests/test-pearl-teams.el b/tests/test-pearl-teams.el
new file mode 100644
index 0000000..dc7e41c
--- /dev/null
+++ b/tests/test-pearl-teams.el
@@ -0,0 +1,109 @@
+;;; test-pearl-teams.el --- Tests for pearl team/project lookups -*- 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:
+
+;; Tests for team, project, member, and label lookups with `request' stubbed.
+;; These exercise the response-unwrapping (assoc nesting) and the
+;; name-to-id resolution used when syncing org headings back to Linear.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+
+;;; pearl-get-teams-async
+
+(ert-deftest test-pearl-get-teams-async-parses-nodes ()
+ "Teams are unwrapped from data.teams.nodes and passed to the callback."
+ (let ((got nil)
+ (pearl--cache-teams nil))
+ (testutil-linear-with-response
+ '((data (teams (nodes . (((id . "t1") (name . "Eng"))
+ ((id . "t2") (name . "Ops")))))))
+ (pearl-get-teams-async (lambda (teams) (setq got teams))))
+ (should (= 2 (length got)))
+ (should (string-equal "Eng" (cdr (assoc 'name (car got)))))))
+
+;;; pearl--get-team-id-by-name
+
+(ert-deftest test-pearl-get-team-id-by-name-found ()
+ "A team whose name matches resolves to its id."
+ (let ((pearl--cache-teams nil))
+ (testutil-linear-with-response
+ '((data (teams (nodes . (((id . "t1") (name . "Eng")))))))
+ (should (string-equal "t1" (pearl--get-team-id-by-name "Eng"))))))
+
+(ert-deftest test-pearl-get-team-id-by-name-not-found ()
+ "A name with no matching team resolves to nil."
+ (let ((pearl--cache-teams nil))
+ (testutil-linear-with-response
+ '((data (teams (nodes . (((id . "t1") (name . "Eng")))))))
+ (should (null (pearl--get-team-id-by-name "Marketing"))))))
+
+(ert-deftest test-pearl-team-id-lookup-caches-teams ()
+ "A second team-id lookup reuses the cached team list, no new request."
+ (let ((pearl-api-key "test-key")
+ (pearl--cache-teams nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (setq calls (1+ calls))
+ (funcall (plist-get args :success)
+ :data '((data (teams (nodes . (((id . "t1") (name . "Eng"))
+ ((id . "t2") (name . "Ops"))))))))) ))
+ (should (string-equal "t1" (pearl--get-team-id-by-name "Eng")))
+ (should (string-equal "t2" (pearl--get-team-id-by-name "Ops")))
+ (should (= 1 calls)))))
+
+;;; pearl-get-projects
+
+(ert-deftest test-pearl-get-projects-converts-vector-to-list ()
+ "Projects come back as a vector from json-read and are returned as a list."
+ (testutil-linear-with-response
+ '((data (team (projects (nodes . [((id . "p1") (name . "Platform"))])))))
+ (let ((projects (pearl-get-projects "team-1")))
+ (should (listp projects))
+ (should (= 1 (length projects)))
+ (should (string-equal "Platform" (cdr (assoc 'name (car projects))))))))
+
+;;; pearl-get-issue-types
+
+(ert-deftest test-pearl-get-issue-types-maps-name-to-id ()
+ "Issue-type labels are returned as a name -> id alist."
+ (testutil-linear-with-response
+ '((data (team (labels (nodes . (((id . "l1") (name . "bug"))
+ ((id . "l2") (name . "feature"))))))))
+ (let ((types (pearl-get-issue-types "team-1")))
+ (should (string-equal "l1" (cdr (assoc "bug" types))))
+ (should (string-equal "l2" (cdr (assoc "feature" types)))))))
+
+;;; pearl-get-team-members
+
+(ert-deftest test-pearl-get-team-members-prefers-display-name ()
+ "Members map their display name (falling back to name) to their id."
+ (testutil-linear-with-response
+ '((data (team (members (nodes . (((id . "m1") (name . "Ada Lovelace") (displayName . "Ada"))
+ ((id . "m2") (name . "Alan Turing"))))))))
+ (let ((members (pearl-get-team-members "team-1")))
+ (should (string-equal "m1" (cdr (assoc "Ada" members))))
+ (should (string-equal "m2" (cdr (assoc "Alan Turing" members)))))))
+
+(provide 'test-pearl-teams)
+;;; test-pearl-teams.el ends here
diff --git a/tests/test-pearl-title-sync.el b/tests/test-pearl-title-sync.el
new file mode 100644
index 0000000..6fbd284
--- /dev/null
+++ b/tests/test-pearl-title-sync.el
@@ -0,0 +1,165 @@
+;;; test-pearl-title-sync.el --- Tests for title sync-back -*- 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:
+
+;; Tests for explicit title sync-back, a separate path from the description
+;; sync that shares the `pearl--sync-decision' gate. Covers the title
+;; extractor, the title fetch and update helpers (stubbed at the HTTP
+;; boundary), the command's no-op / push / conflict branches, and the
+;; deliberate bracket-stripping lossiness: a bracketed remote title renders
+;; with its stored hash matching the stripped heading, so a no-op sync makes
+;; no API call and never clobbers the brackets on Linear.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+(defmacro test-pearl--in-org (content &rest body)
+ "Run BODY in an org-mode temp buffer holding CONTENT, default mapping bound."
+ (declare (indent 1))
+ `(let ((pearl-state-to-todo-mapping
+ '(("Todo" . "TODO") ("In Progress" . "IN-PROGRESS") ("Done" . "DONE")))
+ (org-todo-keywords '((sequence "TODO" "IN-PROGRESS" "|" "DONE"))))
+ (with-temp-buffer
+ (insert ,content)
+ (org-mode)
+ (goto-char (point-min))
+ ,@body)))
+
+;;; --issue-title-at-point
+
+(ert-deftest test-pearl-issue-title-strips-keyword-and-cookie ()
+ "The title extractor returns the heading text without TODO, priority, tags."
+ (test-pearl--in-org
+ "*** TODO [#B] My issue title :tag:\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n"
+ (should (string= "My issue title" (pearl--issue-title-at-point)))))
+
+;;; network helpers
+
+(ert-deftest test-pearl-fetch-issue-title-parses-payload ()
+ "The title fetch returns the remote title and timestamp."
+ (testutil-linear-with-response
+ '((data (issue (title . "Remote title") (updatedAt . "2026-05-23T00:00:00.000Z"))))
+ (let (result)
+ (pearl--fetch-issue-title-async "a" (lambda (r) (setq result r)))
+ (should (string= "Remote title" (plist-get result :title)))
+ (should (string= "2026-05-23T00:00:00.000Z" (plist-get result :updated-at))))))
+
+(ert-deftest test-pearl-update-issue-title-success ()
+ "A successful title issueUpdate reports success."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . t)
+ (issue (id . "a") (updatedAt . "2026-05-23T01:00:00.000Z")))))
+ (let (result)
+ (pearl--update-issue-title-async "a" "New" (lambda (r) (setq result r)))
+ (should (eq t (plist-get result :success))))))
+
+(ert-deftest test-pearl-update-issue-title-soft-fail ()
+ "A non-success title issueUpdate reports failure rather than erroring."
+ (testutil-linear-with-response
+ '((data (issueUpdate (success . :json-false) (issue . nil))))
+ (let ((called nil) result)
+ (pearl--update-issue-title-async "a" "New" (lambda (r) (setq called t result r)))
+ (should called)
+ (should-not (plist-get result :success)))))
+
+;;; command branches
+
+(defmacro test-pearl--with-title-stubs (remote-title update-spy &rest body)
+ "Run BODY with the title fetch/update helpers stubbed.
+REMOTE-TITLE is the plist the fetch hands its callback. UPDATE-SPY collects
+the titles pushed to the update helper, which reports success."
+ (declare (indent 2))
+ `(cl-letf (((symbol-function 'pearl--fetch-issue-title-async)
+ (lambda (_id cb) (funcall cb ,remote-title)))
+ ((symbol-function 'pearl--update-issue-title-async)
+ (lambda (_id title cb)
+ (push title ,update-spy)
+ (funcall cb '(:success t :updated-at "2026-05-23T02:00:00.000Z")))))
+ ,@body))
+
+(ert-deftest test-pearl-sync-title-noop-skips-network ()
+ "No title edit: neither the fetch nor the update helper is called."
+ (let ((fetched nil) (updates nil))
+ (test-pearl--in-org
+ (format "*** TODO [#B] Same Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
+ (secure-hash 'sha256 "Same Title"))
+ (cl-letf (((symbol-function 'pearl--fetch-issue-title-async)
+ (lambda (&rest _) (setq fetched t)))
+ ((symbol-function 'pearl--update-issue-title-async)
+ (lambda (&rest _) (push 'called updates))))
+ (pearl-sync-current-issue-title)
+ (should-not fetched)
+ (should-not updates)))))
+
+(ert-deftest test-pearl-sync-title-push-advances-provenance ()
+ "An edited title against an unchanged remote pushes and advances the hash."
+ (let ((updates nil))
+ (test-pearl--in-org
+ (format "*** TODO [#B] Edited Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
+ (secure-hash 'sha256 "Old Title"))
+ (test-pearl--with-title-stubs '(:title "Old Title" :updated-at "t0") updates
+ (pearl-sync-current-issue-title)
+ (should (equal '("Edited Title") updates))
+ (should (string= (secure-hash 'sha256 "Edited Title")
+ (org-entry-get nil "LINEAR-TITLE-SHA256")))))))
+
+(ert-deftest test-pearl-sync-title-conflict-refuses ()
+ "Title edited locally and changed on the remote too: refuse, do not push."
+ (let ((updates nil)
+ (stored (secure-hash 'sha256 "Old Title")))
+ (test-pearl--in-org
+ (format "*** TODO [#B] Edited Title\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
+ stored)
+ (test-pearl--with-title-stubs '(:title "Remote Changed Title" :updated-at "t1") updates
+ ;; On conflict the command now prompts; cancel keeps the old behavior.
+ (cl-letf (((symbol-function 'pearl--read-conflict-resolution)
+ (lambda (_label) 'cancel)))
+ (pearl-sync-current-issue-title)
+ (should-not updates)
+ (should (string= stored (org-entry-get nil "LINEAR-TITLE-SHA256"))))))))
+
+(ert-deftest test-pearl-sync-title-bracketed-remote-is-noop ()
+ "A bracketed remote title renders stripped; a no-op sync makes no API call.
+This is the deliberate bracket-stripping lossiness: the stored hash is of the
+stripped heading, so an unedited bracketed title is never clobbered on Linear."
+ (let ((fetched nil) (updates nil))
+ (test-pearl--in-org
+ ;; remote title "Fix [URGENT] bug" renders to heading "Fix URGENT bug";
+ ;; the stored hash is of the stripped form.
+ (format "*** TODO [#B] Fix URGENT bug\n:PROPERTIES:\n:LINEAR-ID: a\n:LINEAR-TITLE-SHA256: %s\n:END:\n"
+ (secure-hash 'sha256 "Fix URGENT bug"))
+ (cl-letf (((symbol-function 'pearl--fetch-issue-title-async)
+ (lambda (&rest _) (setq fetched t)))
+ ((symbol-function 'pearl--update-issue-title-async)
+ (lambda (&rest _) (push 'called updates))))
+ (pearl-sync-current-issue-title)
+ (should-not fetched)
+ (should-not updates)))))
+
+(ert-deftest test-pearl-sync-title-not-on-issue-errors ()
+ "Running the command outside a Linear issue heading signals a user error."
+ (test-pearl--in-org "* Plain heading\nno id\n"
+ (should-error (pearl-sync-current-issue-title) :type 'user-error)))
+
+(provide 'test-pearl-title-sync)
+;;; test-pearl-title-sync.el ends here
diff --git a/tests/test-pearl-views.el b/tests/test-pearl-views.el
new file mode 100644
index 0000000..2a5d6bd
--- /dev/null
+++ b/tests/test-pearl-views.el
@@ -0,0 +1,130 @@
+;;; test-pearl-views.el --- Tests for Linear Custom Views -*- 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:
+
+;; Tests for reading and running Linear Custom Views: the cached views list,
+;; the server-side `--query-view-async' run, `pearl-run-view', the view
+;; branch of refresh, and opening the active view in the browser. HTTP is
+;; stubbed.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'testutil-request (expand-file-name "testutil-request.el"))
+(require 'cl-lib)
+
+;;; --query-view-async
+
+(ert-deftest test-pearl-query-view-async-extracts-issues ()
+ "Running a view extracts the server-side issue nodes into an ok result."
+ (testutil-linear-with-response
+ '((data (customView
+ (issues (nodes . [((id . "i1") (identifier . "ENG-1") (title . "A"))])
+ (pageInfo (hasNextPage . :json-false) (endCursor . nil))))))
+ (let (result)
+ (pearl--query-view-async "view-1" (lambda (r) (setq result r)))
+ (should (eq 'ok (pearl--query-result-status result)))
+ (should (= 1 (length (pearl--query-result-issues result)))))))
+
+;;; --custom-views (cached)
+
+(ert-deftest test-pearl-custom-views-caches ()
+ "The views list is fetched once and served from cache."
+ (let ((pearl-api-key "test-key")
+ (pearl--cache-views nil)
+ (calls 0))
+ (cl-letf (((symbol-function 'request)
+ (lambda (_url &rest args)
+ (cl-incf calls)
+ (funcall (plist-get args :success) :data
+ '((data (customViews
+ (nodes . [((id . "v1") (name . "My View") (url . "https://x"))])
+ (pageInfo (hasNextPage . :json-false)))))))))
+ (let ((views (pearl--custom-views)))
+ (should (= 1 (length views)))
+ (pearl--custom-views)
+ (should (= 1 calls))))))
+
+;;; run-view
+
+(ert-deftest test-pearl-run-view-renders-with-view-source ()
+ "Running a view resolves its id and renders with a view-typed source."
+ (let ((ran-id nil) (rendered-source nil))
+ (cl-letf (((symbol-function 'pearl--custom-views)
+ (lambda (&optional _force)
+ '(((id . "v1") (name . "My View") (url . "https://linear.app/view/v1")))))
+ ((symbol-function 'pearl--query-view-async)
+ (lambda (id cb) (setq ran-id id)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--render-query-result)
+ (lambda (_result source) (setq rendered-source source))))
+ (pearl-run-view "My View")
+ (should (string= "v1" ran-id))
+ (should (eq 'view (plist-get rendered-source :type)))
+ (should (string= "v1" (plist-get rendered-source :id)))
+ (should (string= "https://linear.app/view/v1" (plist-get rendered-source :url))))))
+
+;;; refresh-current-view, view branch
+
+(ert-deftest test-pearl-refresh-current-view-runs-view-source ()
+ "Refresh on a view source calls the view query, not the filter query."
+ (let ((view-ran nil)
+ (source '(:type view :name "My View" :id "v1" :url "https://x")))
+ (with-temp-buffer
+ (insert (format "#+title: Linear — My View\n#+LINEAR-SOURCE: %s\n\n"
+ (prin1-to-string source)))
+ (org-mode)
+ (cl-letf (((symbol-function 'pearl--query-view-async)
+ (lambda (id cb) (setq view-ran id)
+ (funcall cb (pearl--make-query-result 'ok :issues nil))))
+ ((symbol-function 'pearl--merge-query-result)
+ (lambda (&rest _) nil)))
+ (pearl-refresh-current-view)
+ (should (string= "v1" view-ran))))))
+
+;;; open-current-view-in-linear
+
+(ert-deftest test-pearl-open-current-view-visits-url ()
+ "Opening the active view visits the source's url."
+ (let ((visited nil)
+ (source '(:type view :name "My View" :id "v1" :url "https://linear.app/view/v1")))
+ (with-temp-buffer
+ (insert (format "#+LINEAR-SOURCE: %s\n" (prin1-to-string source)))
+ (org-mode)
+ (cl-letf (((symbol-function 'browse-url) (lambda (u &rest _) (setq visited u))))
+ (pearl-open-current-view-in-linear)
+ (should (string= "https://linear.app/view/v1" visited))))))
+
+(ert-deftest test-pearl-open-current-view-no-url-errors ()
+ "Opening a non-view or url-less source signals a user error."
+ (let ((source '(:type filter :name "My open issues" :filter (:assignee :me))))
+ (with-temp-buffer
+ (insert (format "#+LINEAR-SOURCE: %s\n" (prin1-to-string source)))
+ (org-mode)
+ (should-error (pearl-open-current-view-in-linear) :type 'user-error))))
+
+;;; the view query fetches comments too
+
+(ert-deftest test-pearl-view-issues-query-requests-comments ()
+ "The Custom View query selects comments, so a view-populated list shows them."
+ (should (string-match-p "comments[[:space:]]*{[[:space:]]*nodes" pearl--view-issues-query)))
+
+(provide 'test-pearl-views)
+;;; test-pearl-views.el ends here
diff --git a/tests/testutil-fixtures.el b/tests/testutil-fixtures.el
new file mode 100644
index 0000000..5c55c7b
--- /dev/null
+++ b/tests/testutil-fixtures.el
@@ -0,0 +1,105 @@
+;;; testutil-fixtures.el --- representative Linear API response fixtures -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; Small, representative response fixtures for the issue-query and
+;; representation build. They cover an assignedIssues page, a top-level
+;; issues(filter:) page, custom views, a custom view's issues, an issue with
+;; comments, and an issue with null/missing optional fields.
+;;
+;; Shapes mirror what `json-read' returns from the live API: symbol-keyed
+;; alists, `t' / `:json-false' for JSON booleans, and a missing key (rather
+;; than an explicit value) for absent optional fields. Not a test file (no
+;; `test-' prefix), so the suite runner ignores it; tests `require' it.
+;;
+;; These let normalization, query, and render tests run against stable
+;; inputs without a live workspace. Captured from the documented schema
+;; shapes; replace with real recorded responses once a live key is wired in.
+
+;;; Code:
+
+(defun testutil-linear-fixture-issue-full ()
+ "A fully-populated issue node, every optional field present."
+ '((id . "uuid-1")
+ (identifier . "ENG-42")
+ (title . "Fix the thing")
+ (description . "Line one\nLine two")
+ (priority . 2)
+ (updatedAt . "2026-05-20T12:00:00.000Z")
+ (state . ((id . "state-1") (name . "In Progress") (type . "started") (color . "#fff")))
+ (assignee . ((id . "user-1") (name . "Craig") (email . "c@example.com")))
+ (team . ((id . "team-1") (key . "ENG") (name . "Engineering")))
+ (project . ((id . "proj-1") (name . "Platform")))
+ (cycle . ((id . "cycle-1") (number . 12) (name . "Cycle 12")))
+ (labels . ((nodes . (((id . "lbl-1") (name . "bug"))
+ ((id . "lbl-2") (name . "backend"))))))))
+
+(defun testutil-linear-fixture-issue-null-fields ()
+ "An issue with optional fields absent or null (project/labels/assignee/cycle).
+Description is JSON null; labels is an empty connection."
+ '((id . "uuid-2")
+ (identifier . "ENG-7")
+ (title . "Bare issue")
+ (description)
+ (priority . 0)
+ (updatedAt . "2026-05-19T08:30:00.000Z")
+ (state . ((id . "state-2") (name . "Todo") (type . "unstarted")))
+ (assignee)
+ (team . ((id . "team-1") (key . "ENG") (name . "Engineering")))
+ (project)
+ (cycle)
+ (labels . ((nodes . ())))))
+
+(defun testutil-linear-fixture-assigned-issues-page ()
+ "A viewer.assignedIssues page: two issues, no next page."
+ `((data (viewer (assignedIssues
+ (nodes . (,(testutil-linear-fixture-issue-full)
+ ,(testutil-linear-fixture-issue-null-fields)))
+ (pageInfo (hasNextPage . :json-false) (endCursor . "cursor-1")))))))
+
+(defun testutil-linear-fixture-issues-filter-page ()
+ "A top-level issues(filter:) page: one issue, has a next page."
+ `((data (issues
+ (nodes . (,(testutil-linear-fixture-issue-full)))
+ (pageInfo (hasNextPage . t) (endCursor . "cursor-2"))))))
+
+(defun testutil-linear-fixture-custom-views ()
+ "A customViews connection: one shared workspace view, one personal team view."
+ '((data (customViews
+ (nodes . (((id . "cv-1") (name . "My open work") (description . "Everything open assigned to me")
+ (shared . :json-false) (team) (icon . "Inbox") (color . "#aabbcc")
+ (owner . ((id . "user-1") (name . "Craig"))))
+ ((id . "cv-2") (name . "Eng in progress") (description)
+ (shared . t) (team . ((id . "team-1") (key . "ENG") (name . "Engineering")))
+ (icon) (color)
+ (owner . ((id . "user-1") (name . "Craig"))))))
+ (pageInfo (hasNextPage . :json-false) (endCursor . "cv-cursor"))))))
+
+(defun testutil-linear-fixture-custom-view-issues ()
+ "A customView(id).issues page: the view's filter resolved server-side."
+ `((data (customView
+ (id . "cv-1")
+ (name . "My open work")
+ (issues
+ (nodes . (,(testutil-linear-fixture-issue-full)))
+ (pageInfo (hasNextPage . :json-false) (endCursor . "cvi-cursor")))))))
+
+(defun testutil-linear-fixture-issue-with-comments ()
+ "An issue carrying a comments connection, oldest first."
+ `((id . "uuid-1")
+ (identifier . "ENG-42")
+ (title . "Fix the thing")
+ (description . "Body text")
+ (comments (nodes . (((id . "cm-1") (body . "First comment")
+ (createdAt . "2026-05-18T09:00:00.000Z")
+ (user . ((id . "user-2") (name . "Alice"))))
+ ((id . "cm-2") (body . "Second comment, **bold**")
+ (createdAt . "2026-05-19T10:30:00.000Z")
+ (user . ((id . "user-1") (name . "Craig")))))))))
+
+(provide 'testutil-fixtures)
+;;; testutil-fixtures.el ends here
diff --git a/tests/testutil-request.el b/tests/testutil-request.el
new file mode 100644
index 0000000..a517bb6
--- /dev/null
+++ b/tests/testutil-request.el
@@ -0,0 +1,49 @@
+;;; testutil-request.el --- request mocking helpers for pearl tests -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;;; Commentary:
+
+;; Shared helpers for stubbing the `request' library at the HTTP boundary.
+;; A stub invokes the package's own :success or :error callback synchronously
+;; with canned, json-read-shaped data, so the response-parsing and callback
+;; logic runs for real without any network. Not a test file itself (no
+;; test- prefix), so the suite runner ignores it; test files `require' it.
+
+;;; Code:
+
+(require 'cl-lib)
+
+(defun testutil-linear-request-success (data)
+ "Return a `request' replacement that invokes its :success callback with DATA."
+ (lambda (_url &rest args)
+ (let ((cb (plist-get args :success)))
+ (when cb (funcall cb :data data)))))
+
+(defun testutil-linear-request-error (msg)
+ "Return a `request' replacement that invokes its :error callback with MSG.
+The :response carries a real `request-response' struct, matching what the
+live library passes, so the package's status-code logging doesn't choke."
+ (lambda (_url &rest args)
+ (let ((cb (plist-get args :error)))
+ (when cb
+ (funcall cb :error-thrown msg
+ :response (make-request-response :status-code 500)
+ :data nil)))))
+
+(defmacro testutil-linear-with-response (data &rest body)
+ "Run BODY with `request' stubbed to succeed with DATA and an API key set."
+ (declare (indent 1))
+ `(let ((pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'request) (testutil-linear-request-success ,data)))
+ ,@body)))
+
+(defmacro testutil-linear-with-error (msg &rest body)
+ "Run BODY with `request' stubbed to fail with MSG and an API key set."
+ (declare (indent 1))
+ `(let ((pearl-api-key "test-key"))
+ (cl-letf (((symbol-function 'request) (testutil-linear-request-error ,msg)))
+ ,@body)))
+
+(provide 'testutil-request)
+;;; testutil-request.el ends here