thunderbird-patch-review
Easy-to-use patch review interface for Thunderbird
Contribute: ~marcc/thunderbird-review-plugin@lists.sr.ht
git clone git://mccd.space/thunderbird-patch-review
| Log | Files | Refs | README | LICENSE |
commit af77b22e578a1acf65a8e967b81760c7f14181f6 Author: Marc <marc@coquand.email> Date: Sat, 18 Jul 2026 11:42:35 +0200 Initial commit Diffstat:
| A | LICENSE | | | 662 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | README | | | 127 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/background.js | | | 206 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/icons/review.svg | | | 12 | ++++++++++++ |
| A | extension/manifest.json | | | 39 | +++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/diff-parse.js | | | 152 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/intraline.js | | | 83 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/native.js | | | 62 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/patch-detect.js | | | 88 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/reply-format.js | | | 91 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/review-store.js | | | 43 | +++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/series.js | | | 123 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/modules/sourcehut.js | | | 22 | ++++++++++++++++++++++ |
| A | extension/options/options.html | | | 55 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/options/options.js | | | 86 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/review/review.css | | | 324 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/review/review.html | | | 47 | +++++++++++++++++++++++++++++++++++++++++++++++ |
| A | extension/review/review.js | | | 469 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | host/host-manifest.json.in | | | 7 | +++++++ |
| A | host/install-host.sh | | | 31 | +++++++++++++++++++++++++++++++ |
| A | host/review-host.sh | | | 235 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | patch-review.1 | | | 169 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | pre-commit.sh | | | 5 | +++++ |
| A | tests/fixtures/cover.body | | | 17 | +++++++++++++++++ |
| A | tests/fixtures/patch1.body | | | 35 | +++++++++++++++++++++++++++++++++++ |
| A | tests/fixtures/patch2.body | | | 20 | ++++++++++++++++++++ |
| A | tests/fixtures/patch3.body | | | 18 | ++++++++++++++++++ |
| A | tests/gen-fixtures.sh | | | 98 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | tests/host-test.sh | | | 244 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | tests/run.mjs | | | 262 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
30 files changed, 3832 insertions(+), 0 deletions(-)
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,662 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are 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.
+
+ 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.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ 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 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 work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ 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 AGPL, see
+<https://www.gnu.org/licenses/>.
+
diff --git a/README b/README
@@ -0,0 +1,127 @@
+PATCH-REVIEW(1) Thunderbird Patch Review PATCH-REVIEW(1)
+
+NAME
+ patch-review - review git patches from email inside Thunderbird
+
+SYNOPSIS
+ A Thunderbird MailExtension plus a native messaging host. Open a patch
+ email, press Review, comment on hunks, send the review as a mailing-
+ list reply, and apply the series to a local repository with git-am(1).
+
+DESCRIPTION
+ patch-review brings a review interface to git-by-email workflows
+ (git-send-email(1) style). When a displayed message looks like a patch
+ -- a [PATCH] subject tag or a unified diff in the body -- the Review
+ button in the message header becomes active. It opens a dedicated
+ review tab that shows the whole series: cover letter, per-patch commit
+ messages, and every file and hunk rendered as a colored diff. Within
+ replaced lines, the span that actually changed gets a stronger
+ highlight, computed from the common prefix and suffix of each
+ deleted/added line pair.
+
+ Clicking a diff line attaches an inline comment to it; comments are
+ saved as drafts (keyed by Message-ID) and survive restarting
+ Thunderbird. Send review turns the comments of each commented patch
+ into one plain-text reply-to-all, quoting the relevant hunks kernel-
+ style with the comments interleaved unquoted below the lines they
+ target, and sends the replies immediately; the draft comments are then
+ cleared and the review tab closes. The attached drop-down offers
+ Preview in compose instead, which leaves the reply drafts open in
+ compose windows. A reply that fails to send also stays open, so
+ nothing is lost. Apply series hands the raw patch messages to the
+ native host, which runs git am --3way in the configured repository and
+ can then launch your editor. On conflict the host runs git am --abort
+ and reports the output, leaving the repository untouched.
+
+INSTALLATION
+ 1. Native host
+ Run host/install-host.sh once. It installs the native messaging
+ manifest into the per-user Mozilla and Thunderbird locations.
+ The host itself is strict POSIX sh(1) and needs only POSIX
+ utilities plus git(1).
+
+ 2. Extension (temporary, for development)
+ In Thunderbird open Tools -> Developer Tools -> Debug Add-ons,
+ choose Load Temporary Add-on..., and pick
+ extension/manifest.json.
+
+ 3. Extension (permanent)
+ Zip the extension/ directory contents (manifest.json at the
+ archive root), rename the archive to patch-review.xpi, and
+ install it from the Add-ons Manager gear menu.
+
+ Restart Thunderbird after installing the host so it is picked up.
+
+CONFIGURATION
+ Open the extension's preferences:
+
+ Editor command
+ Run from the repository after a successful apply, with the
+ changed files as arguments. Use a non-blocking GUI-capable
+ command such as code -n or emacsclient -n; a bare terminal
+ editor has no terminal to open in. Empty means $EDITOR.
+
+ Apply strategy
+ git am --3way (default) or plain git am.
+
+ Repository mappings
+ Substring matches against a patch's List-Id header or sender,
+ mapped to an absolute repository path. When no mapping matches,
+ the last path a series was applied to is used; it is remembered
+ until changed.
+
+ Repository paths can be chosen with the Browse button, which opens a
+ native directory chooser through the host (zenity(1), kdialog(1), or
+ osascript(1), whichever is found); the path field stays editable either
+ way.
+
+SOURCEHUT INTEGRATION
+ When the series arrives via a sourcehut mailing list (a lists.sr.ht
+ address in the List-Id or recipients), the review tab shows a set
+ status checkbox with the patchset states sourcehut understands:
+ APPROVED, NEEDS_REVISION, REJECTED, PROPOSED, SUPERSEDED, APPLIED.
+ When enabled, the first reply of the review carries an
+ X-Sourcehut-Patchset-Update header with the chosen status, which
+ sourcehut applies to the patchset. A status can also be sent without
+ any line comments -- Send review then sends a single, empty reply to
+ the cover letter (or first patch) carrying just the header.
+
+REVIEW WORKFLOW
+ 1. Open any message of the series; press Review.
+ 2. The tab collects the sibling patches from the same folder
+ (same series tag, version, and patch count).
+ 3. Click lines, write comments; Ctrl+Enter saves.
+ "Comment on patch" attaches a general remark instead.
+ 4. Send review: one reply per commented patch, sent
+ immediately (or use the drop-down to preview first).
+ 5. Apply series: git am into the mapped repository,
+ then Open editor on the touched files.
+
+FILES
+ extension/
+ The MailExtension (manifest v2, vanilla ES modules, no build
+ step).
+
+ host/review-host.sh
+ Native messaging host. Speaks the length-prefixed framing with
+ dd(1) and od(1), decodes the base64 envelope with awk(1), and
+ never parses JSON.
+
+ ~/.mozilla/native-messaging-hosts/email.coquand.patchreview.json
+ Installed host manifest (also under ~/.thunderbird).
+
+ tests/ node tests/run.mjs exercises the pure modules (subject parsing,
+ diff parsing, reply formatting) against fixtures generated by
+ git-format-patch(1); sh tests/host-test.sh drives the host end-
+ to-end, including a conflicting apply. Regenerate fixtures with
+ tests/gen-fixtures.sh.
+
+CAVEATS
+ Patches arriving as attachments, HTML-only mail, and reviewing from a
+ git worktree preview are not supported yet. Series collection searches
+ the folder of the opened message only.
+
+AUTHOR
+ Marc Coquand <marc@coquand.email>
+
+patch-review 0.1.0 July 2026 PATCH-REVIEW(1)
diff --git a/extension/background.js b/extension/background.js
@@ -0,0 +1,206 @@
+// Background script: patch detection on displayed messages, opening the
+// review tab, and servicing requests from it (series data, compose replies,
+// native-host apply/edit).
+//
+// MV2 background pages can't be ES modules, so shared code is pulled in
+// with dynamic import().
+
+"use strict";
+
+const MBOX_FROM = "From nobody Mon Sep 17 00:00:00 2001\n";
+
+let modulesPromise = null;
+function mods() {
+ if (!modulesPromise) {
+ modulesPromise = Promise.all([
+ import(messenger.runtime.getURL("modules/patch-detect.js")),
+ import(messenger.runtime.getURL("modules/series.js")),
+ import(messenger.runtime.getURL("modules/native.js")),
+ import(messenger.runtime.getURL("modules/sourcehut.js")),
+ ]).then((loaded) => Object.assign({}, ...loaded));
+ }
+ return modulesPromise;
+}
+
+async function getConfig() {
+ const found = await messenger.storage.local.get("config");
+ return Object.assign({ editor: "", strategy: "am3", mappings: [] }, found.config);
+}
+
+// ---------------------------------------------------------------------------
+// Detection: enable the "Review" button only on patch messages.
+
+messenger.messageDisplay.onMessageDisplayed.addListener(async (tab, message) => {
+ try {
+ const { isPatchMessage, parseSubject, getPlainBody } = await mods();
+ let patch = Boolean(parseSubject(message.subject));
+ if (!patch) {
+ // Subject gave no hint; look for a diff in the body.
+ patch = isPatchMessage(message.subject, await getPlainBody(message.id));
+ }
+ await messenger.messageDisplayAction[patch ? "enable" : "disable"](tab.id);
+ } catch (e) {
+ console.warn("patch-review: detection failed", e);
+ }
+});
+
+messenger.messageDisplayAction.onClicked.addListener(async (tab) => {
+ const message = await messenger.messageDisplay.getDisplayedMessage(tab.id);
+ if (message) {
+ await messenger.tabs.create({
+ url: messenger.runtime.getURL(`review/review.html?mid=${message.id}`),
+ });
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Repo resolution: match the message's List-Id (or author) against the
+// configured mappings.
+
+async function listKeysFor(messageId) {
+ const keys = [];
+ try {
+ const full = await messenger.messages.getFull(messageId);
+ const listId = full.headers && full.headers["list-id"];
+ if (listId && listId.length) {
+ keys.push(listId[0]);
+ }
+ } catch (e) {
+ // headers unavailable; fall through to author only
+ }
+ return keys;
+}
+
+function resolveRepo(config, keys) {
+ for (const mapping of config.mappings || []) {
+ if (!mapping.match || !mapping.repo) {
+ continue;
+ }
+ if (keys.some((k) => k && k.toLowerCase().includes(mapping.match.toLowerCase()))) {
+ return mapping.repo;
+ }
+ }
+ return "";
+}
+
+// ---------------------------------------------------------------------------
+// Requests from the review tab.
+
+messenger.runtime.onMessage.addListener((request, sender, sendResponse) => {
+ handle(request)
+ .then(sendResponse)
+ .catch((e) => sendResponse({ ok: false, error: e.message || String(e) }));
+ return true; // async response
+});
+
+async function handle(request) {
+ const m = await mods();
+
+ switch (request.type) {
+ case "get-series": {
+ const anchor = await messenger.messages.get(request.messageId);
+ const series = await m.collectSeries(anchor);
+ const keys = [...(await listKeysFor(request.messageId)), anchor.author];
+ const config = await getConfig();
+ const { lastRepo } = await messenger.storage.local.get("lastRepo");
+ return {
+ ok: true,
+ series,
+ anchorHeaderMessageId: anchor.headerMessageId,
+ // Explicit mapping first, otherwise the last path that was applied to.
+ repo: resolveRepo(config, keys) || lastRepo || "",
+ listKeys: keys,
+ isSourcehut: m.isSourcehutList([
+ ...keys,
+ ...(anchor.recipients || []),
+ ...(anchor.ccList || []),
+ ]),
+ config,
+ };
+ }
+
+ case "send-review": {
+ // mode "send": send each reply immediately (the compose window opens
+ // and closes; the WebExtension API has no headless send).
+ // mode "preview": leave the compose windows open for the user.
+ const send = request.mode === "send";
+ const status = m.SOURCEHUT_STATUSES.includes(request.status) ? request.status : null;
+ const errors = [];
+ let done = 0;
+ for (const [i, reply] of request.replies.entries()) {
+ const details = {
+ isPlainText: true,
+ plainTextBody: reply.body,
+ };
+ // One status update per patchset: only the first reply carries it.
+ if (status && i === 0) {
+ details.customHeaders = [{ name: m.STATUS_HEADER, value: status }];
+ }
+ const tab = await messenger.compose.beginReply(reply.messageId, "replyToAll", details);
+ if (!send) {
+ done++;
+ continue;
+ }
+ try {
+ await messenger.compose.sendMessage(tab.id, { mode: "sendNow" });
+ done++;
+ } catch (e) {
+ // Leave the failed compose window open so nothing is lost.
+ errors.push(`reply ${i + 1}: ${e.message}`);
+ }
+ }
+ return {
+ ok: errors.length === 0,
+ sent: send ? done : 0,
+ opened: send ? 0 : done,
+ error: errors.length ? `Some replies were not sent:\n${errors.join("\n")}` : undefined,
+ };
+ }
+
+ case "apply-series": {
+ const config = await getConfig();
+ // Remember the path for the next review, until it is changed again.
+ await messenger.storage.local.set({ lastRepo: request.repo });
+ const patches = [];
+ for (const messageId of request.messageIds) {
+ patches.push(await rawMessage(messageId));
+ }
+ return m.callHost(
+ "apply",
+ { repo: request.repo, strategy: config.strategy || "am3" },
+ patches
+ );
+ }
+
+ case "open-editor": {
+ const config = await getConfig();
+ return m.callHost("edit", {
+ repo: request.repo,
+ editor: config.editor || "",
+ files: (request.files || []).join("\n"),
+ });
+ }
+
+ case "pick-repo":
+ // Native directory chooser; the host reuses the repo field as start dir.
+ return m.callHost("pickdir", { repo: request.start || "" });
+
+ case "ping-host":
+ return m.callHost("ping");
+
+ default:
+ throw new Error(`unknown request: ${request.type}`);
+ }
+}
+
+/** Raw RFC822 source with an mbox From_ separator, as git am expects. */
+async function rawMessage(messageId) {
+ let raw = await messenger.messages.getRaw(messageId);
+ if (raw && typeof raw !== "string") {
+ raw = await raw.text(); // newer TB returns a File
+ }
+ if (!raw.startsWith("From ")) {
+ raw = MBOX_FROM + raw;
+ }
+ return raw;
+}
diff --git a/extension/icons/review.svg b/extension/icons/review.svg
@@ -0,0 +1,12 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <!-- Speech bubble: a review comment on a diff. -->
+ <path d="M7 3h18a5 5 0 0 1 5 5v11a5 5 0 0 1-5 5H15l-7 7v-7H7a5 5 0 0 1-5-5V8a5 5 0 0 1 5-5z"
+ fill="#3b7bd4"/>
+ <!-- Deleted line -->
+ <path d="M9 10.5h7" stroke="#ffb1aa" stroke-width="3" stroke-linecap="round" fill="none"/>
+ <!-- Added line -->
+ <path d="M9 16.5h10" stroke="#8fe6a4" stroke-width="3" stroke-linecap="round" fill="none"/>
+ <!-- Context line -->
+ <path d="M20 10.5h3" stroke="#ffffff" stroke-opacity="0.55" stroke-width="3"
+ stroke-linecap="round" fill="none"/>
+</svg>
diff --git a/extension/manifest.json b/extension/manifest.json
@@ -0,0 +1,39 @@
+{
+ "manifest_version": 2,
+ "name": "Patch Review",
+ "version": "0.1.0",
+ "description": "Review git patches sent by email: inline hunk comments, mailing-list style replies, and one-click apply to a local repository.",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "patchreview@coquand.email",
+ "strict_min_version": "115.0"
+ }
+ },
+ "background": {
+ "scripts": ["background.js"]
+ },
+ "icons": {
+ "16": "icons/review.svg",
+ "32": "icons/review.svg",
+ "48": "icons/review.svg",
+ "96": "icons/review.svg"
+ },
+ "message_display_action": {
+ "default_title": "Review patch",
+ "default_label": "Review",
+ "default_icon": "icons/review.svg"
+ },
+ "options_ui": {
+ "page": "options/options.html",
+ "open_in_tab": false
+ },
+ "permissions": [
+ "messagesRead",
+ "accountsRead",
+ "storage",
+ "compose",
+ "compose.send",
+ "tabs",
+ "nativeMessaging"
+ ]
+}
diff --git a/extension/modules/diff-parse.js b/extension/modules/diff-parse.js
@@ -0,0 +1,152 @@
+// Parse the body of a git format-patch email into commit message, diffstat,
+// and a structured diff. Every parsed element remembers the index of its
+// source line in the email body ("bodyLine") so replies can re-quote the
+// original text verbatim.
+
+const HUNK_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
+const DIFF_GIT_RE = /^diff --git (?:"?a\/(.*?)"?) (?:"?b\/(.*?)"?)$/;
+
+/**
+ * @param {string} text email body
+ * @returns {{
+ * commitMessage: string,
+ * diffstat: string,
+ * files: Array<{
+ * oldPath: string, newPath: string, displayPath: string,
+ * isBinary: boolean, isNew: boolean, isDeleted: boolean, isRename: boolean,
+ * bodyLine: number,
+ * hunks: Array<{
+ * header: string, section: string, bodyLine: number,
+ * oldStart: number, oldCount: number, newStart: number, newCount: number,
+ * lines: Array<{origin: "add"|"del"|"ctx", text: string, raw: string,
+ * bodyLine: number, oldLine: number|null, newLine: number|null}>
+ * }>
+ * }>,
+ * bodyLines: string[]
+ * }}
+ */
+export function parsePatchEmail(text) {
+ const bodyLines = (text || "").split(/\r?\n/);
+ const result = { commitMessage: "", diffstat: "", files: [], bodyLines };
+
+ let firstDiff = bodyLines.findIndex((l) => DIFF_GIT_RE.test(l) || HUNK_RE.test(l));
+ if (firstDiff === -1) {
+ result.commitMessage = text || "";
+ return result;
+ }
+
+ // The commit message ends at the "---" scissors line when present,
+ // otherwise at the first diff line. Between the scissors and the diff
+ // sits the diffstat.
+ let scissors = -1;
+ for (let i = 0; i < firstDiff; i++) {
+ if (/^---\s*$/.test(bodyLines[i])) {
+ scissors = i;
+ }
+ }
+ const messageEnd = scissors === -1 ? firstDiff : scissors;
+ result.commitMessage = bodyLines.slice(0, messageEnd).join("\n").trim();
+ if (scissors !== -1) {
+ result.diffstat = bodyLines.slice(scissors + 1, firstDiff).join("\n").trim();
+ }
+
+ let file = null;
+ let hunk = null;
+ let oldLine = 0;
+ let newLine = 0;
+
+ const startFile = (oldPath, newPath, bodyLine) => {
+ file = {
+ oldPath,
+ newPath,
+ displayPath: newPath !== "/dev/null" && newPath ? newPath : oldPath,
+ isBinary: false,
+ isNew: false,
+ isDeleted: false,
+ isRename: false,
+ bodyLine,
+ hunks: [],
+ };
+ hunk = null;
+ result.files.push(file);
+ };
+
+ for (let i = firstDiff; i < bodyLines.length; i++) {
+ const line = bodyLines[i];
+
+ // git format-patch signature trailer terminates the diff.
+ if (/^-- ?$/.test(line) && hunk) {
+ break;
+ }
+
+ const dg = DIFF_GIT_RE.exec(line);
+ if (dg) {
+ startFile(dg[1], dg[2], i);
+ continue;
+ }
+
+ const h = HUNK_RE.exec(line);
+ if (h) {
+ if (!file) {
+ startFile("", "", i);
+ }
+ hunk = {
+ header: line,
+ section: h[5] || "",
+ bodyLine: i,
+ oldStart: parseInt(h[1], 10),
+ oldCount: h[2] === undefined ? 1 : parseInt(h[2], 10),
+ newStart: parseInt(h[3], 10),
+ newCount: h[4] === undefined ? 1 : parseInt(h[4], 10),
+ consumedOld: 0,
+ consumedNew: 0,
+ lines: [],
+ };
+ oldLine = hunk.oldStart;
+ newLine = hunk.newStart;
+ file.hunks.push(hunk);
+ continue;
+ }
+
+ if (file && !hunk) {
+ // Extended header lines between "diff --git" and the first hunk.
+ if (/^new file mode /.test(line)) file.isNew = true;
+ else if (/^deleted file mode /.test(line)) file.isDeleted = true;
+ else if (/^rename (from|to) /.test(line)) file.isRename = true;
+ else if (/^Binary files /.test(line) || /^GIT binary patch/.test(line)) file.isBinary = true;
+ else if (/^--- /.test(line)) file.oldPath = line.slice(4).replace(/^"?a\//, "").replace(/"$/, "");
+ else if (/^\+\+\+ /.test(line)) {
+ file.newPath = line.slice(4).replace(/^"?b\//, "").replace(/"$/, "");
+ if (file.newPath !== "/dev/null") file.displayPath = file.newPath;
+ }
+ continue;
+ }
+
+ if (hunk) {
+ const c = line[0];
+ if (c === "+") {
+ hunk.lines.push({ origin: "add", text: line.slice(1), raw: line, bodyLine: i, oldLine: null, newLine: newLine++ });
+ hunk.consumedNew++;
+ } else if (c === "-") {
+ hunk.lines.push({ origin: "del", text: line.slice(1), raw: line, bodyLine: i, oldLine: oldLine++, newLine: null });
+ hunk.consumedOld++;
+ } else if (c === " " || line === "") {
+ hunk.lines.push({ origin: "ctx", text: line.slice(1), raw: line, bodyLine: i, oldLine: oldLine++, newLine: newLine++ });
+ hunk.consumedOld++;
+ hunk.consumedNew++;
+ } else if (c === "\\") {
+ // "\ No newline at end of file" — attach as context-like metadata line.
+ hunk.lines.push({ origin: "ctx", text: line, raw: line, bodyLine: i, oldLine: null, newLine: null });
+ } else {
+ // Anything else ends the current hunk (trailing chatter, next header).
+ hunk = null;
+ }
+
+ if (hunk && hunk.consumedOld >= hunk.oldCount && hunk.consumedNew >= hunk.newCount) {
+ hunk = null; // hunk complete
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/extension/modules/intraline.js b/extension/modules/intraline.js
@@ -0,0 +1,83 @@
+// Intra-line ("word level") diff highlighting: within a replacement block —
+// a run of deleted lines followed by a run of added lines — pair the lines
+// index-wise and mark the span that actually changed, computed as everything
+// between the common prefix and common suffix of the pair.
+
+/**
+ * Changed spans for one del/add line pair, as ranges over the line text
+ * (without the +/- prefix). Returns null when the lines share too little
+ * (under ~25% of the longer line) for a partial highlight to mean anything.
+ * A side whose range is empty (pure insertion/removal) should not be
+ * highlighted; computeIntraline already filters those out.
+ */
+export function pairRanges(delText, addText) {
+ const max = Math.min(delText.length, addText.length);
+ let prefix = 0;
+ while (prefix < max && delText[prefix] === addText[prefix]) {
+ prefix++;
+ }
+ let suffix = 0;
+ while (
+ suffix < max - prefix &&
+ delText[delText.length - 1 - suffix] === addText[addText.length - 1 - suffix]
+ ) {
+ suffix++;
+ }
+ if ((prefix + suffix) * 4 < Math.max(delText.length, addText.length)) {
+ return null;
+ }
+ return {
+ del: [prefix, delText.length - suffix],
+ add: [prefix, addText.length - suffix],
+ };
+}
+
+const isNoNewlineMarker = (line) => line.origin === "ctx" && line.raw.startsWith("\\");
+
+/**
+ * @param {Array<{origin: string, text: string, raw: string}>} lines hunk lines
+ * @returns {Array<{start: number, end: number}|null>} per-line changed range
+ * over line.text, null where nothing (or everything) changed
+ */
+export function computeIntraline(lines) {
+ const result = new Array(lines.length).fill(null);
+
+ let i = 0;
+ while (i < lines.length) {
+ if (lines[i].origin !== "del") {
+ i++;
+ continue;
+ }
+ const dels = [];
+ while (i < lines.length && lines[i].origin === "del") {
+ dels.push(i);
+ i++;
+ }
+ // A "\ No newline at end of file" marker may sit between the runs.
+ let j = i;
+ while (j < lines.length && isNoNewlineMarker(lines[j])) {
+ j++;
+ }
+ const adds = [];
+ while (j < lines.length && lines[j].origin === "add") {
+ adds.push(j);
+ j++;
+ }
+
+ const pairs = Math.min(dels.length, adds.length);
+ for (let k = 0; k < pairs; k++) {
+ const ranges = pairRanges(lines[dels[k]].text, lines[adds[k]].text);
+ if (!ranges) {
+ continue;
+ }
+ if (ranges.del[1] > ranges.del[0]) {
+ result[dels[k]] = { start: ranges.del[0], end: ranges.del[1] };
+ }
+ if (ranges.add[1] > ranges.add[0]) {
+ result[adds[k]] = { start: ranges.add[0], end: ranges.add[1] };
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/extension/modules/native.js b/extension/modules/native.js
@@ -0,0 +1,62 @@
+// Bridge to the POSIX-sh native messaging host. The host deliberately avoids
+// a JSON parser, so we send it a single field {d: <base64>} containing a
+// line-based envelope; values that may contain arbitrary characters are
+// base64-encoded again inside the envelope:
+//
+// action apply
+// repo64 <base64 of repo path>
+// editor64 <base64 of editor command>
+// strategy am3
+// patch64 <base64 of one raw patch message> (repeated, in series order)
+// end
+//
+// The host replies with real JSON ({ok, output, ...}), which Thunderbird
+// parses for us.
+
+export const HOST_NAME = "email.coquand.patchreview";
+
+/** base64 of the UTF-8 encoding of s, without line breaks. */
+export function b64(s) {
+ const bytes = new TextEncoder().encode(s);
+ let bin = "";
+ const CHUNK = 0x8000;
+ for (let i = 0; i < bytes.length; i += CHUNK) {
+ bin += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
+ }
+ return btoa(bin);
+}
+
+export function buildEnvelope(action, fields = {}, patches = []) {
+ const lines = [`action ${action}`];
+ for (const [name, value] of Object.entries(fields)) {
+ if (value !== undefined && value !== null && value !== "") {
+ lines.push(`${name}64 ${b64(String(value))}`);
+ }
+ }
+ for (const patch of patches) {
+ lines.push(`patch64 ${b64(patch)}`);
+ }
+ lines.push("end");
+ return lines.join("\n");
+}
+
+/**
+ * One-shot request to the host.
+ * @returns {Promise<{ok: boolean, output?: string, error?: string}>}
+ */
+export async function callHost(action, fields = {}, patches = []) {
+ const envelope = buildEnvelope(action, fields, patches);
+ try {
+ const response = await messenger.runtime.sendNativeMessage(HOST_NAME, { d: b64(envelope) });
+ return response && typeof response === "object"
+ ? response
+ : { ok: false, error: "malformed host response" };
+ } catch (e) {
+ return {
+ ok: false,
+ error:
+ `Could not reach the native host "${HOST_NAME}". ` +
+ `Run host/install-host.sh and restart Thunderbird. (${e.message})`,
+ };
+ }
+}
diff --git a/extension/modules/patch-detect.js b/extension/modules/patch-detect.js
@@ -0,0 +1,88 @@
+// Heuristics for recognizing git patches sent by email (git send-email /
+// git format-patch conventions).
+
+// Matches subject tags like [PATCH], [PATCH 3/7], [RFC PATCH v2 0/5],
+// [PATCH net-next v3 1/2]. Reply prefixes ("Re: ") may precede the tag.
+const SUBJECT_TAG_RE = /^(?:\s*(?:re|aw|fwd?)\s*:\s*)*\[(?<tag>[^\]]*\bPATCH\b[^\]]*)\]\s*(?<title>.*)$/i;
+
+/**
+ * Parse a patch subject line.
+ * @returns {null | {
+ * tag: string, // full text inside the brackets
+ * title: string, // subject after the tag
+ * prefix: string, // tag minus version and n/m, normalized ("PATCH", "RFC PATCH net-next", ...)
+ * version: number, // 1 when no vN marker present
+ * n: number|null, // index within the series, 0 = cover letter
+ * m: number|null // series size, null for a bare [PATCH]
+ * }}
+ */
+export function parseSubject(subject) {
+ const match = SUBJECT_TAG_RE.exec(subject || "");
+ if (!match) {
+ return null;
+ }
+ const tag = match.groups.tag.trim();
+
+ let n = null;
+ let m = null;
+ const nm = /\b(\d+)\s*\/\s*(\d+)\b/.exec(tag);
+ if (nm) {
+ n = parseInt(nm[1], 10);
+ m = parseInt(nm[2], 10);
+ }
+
+ let version = 1;
+ const ver = /\bv(\d+)\b/i.exec(tag);
+ if (ver) {
+ version = parseInt(ver[1], 10);
+ }
+
+ const prefix = tag
+ .replace(nm ? nm[0] : "", "")
+ .replace(ver ? ver[0] : "", "")
+ .split(/\s+/)
+ .filter(Boolean)
+ .join(" ")
+ .toUpperCase();
+
+ return { tag, title: match.groups.title.trim(), prefix, version, n, m };
+}
+
+/** True if the text contains a unified diff. */
+export function bodyLooksLikeDiff(text) {
+ if (!text) {
+ return false;
+ }
+ if (/^diff --git /m.test(text)) {
+ return true;
+ }
+ if (/^--- (?:a\/|\/dev\/null)/m.test(text) && /^\+\+\+ (?:b\/|\/dev\/null)/m.test(text)) {
+ return true;
+ }
+ return /^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/m.test(text);
+}
+
+/**
+ * Decide whether a message is a reviewable patch. The subject tag is the
+ * primary signal; a diff in the body confirms it (and catches patches sent
+ * without the conventional tag). A cover letter (0/m) has no diff but is
+ * still part of a series.
+ */
+export function isPatchMessage(subject, body) {
+ const info = parseSubject(subject);
+ if (info) {
+ return info.n === 0 || bodyLooksLikeDiff(body) || info.m !== null;
+ }
+ return bodyLooksLikeDiff(body);
+}
+
+/**
+ * True when two parsed subjects belong to the same patch series:
+ * same normalized prefix, same version, same series size.
+ */
+export function sameSeries(a, b) {
+ if (!a || !b) {
+ return false;
+ }
+ return a.prefix === b.prefix && a.version === b.version && a.m === b.m;
+}
diff --git a/extension/modules/reply-format.js b/extension/modules/reply-format.js
@@ -0,0 +1,91 @@
+// Turn draft comments into a plain-text, interleaved mailing-list review:
+// the commented regions of the patch are quoted with "> " and each comment
+// sits unquoted below the line it targets — kernel-style.
+
+import { parseLocator, GENERAL } from "./review-store.js";
+
+const CONTEXT_BEFORE = 3; // quoted lines shown above the first comment in a hunk
+
+/**
+ * @param {{commitMessage: string, files: Array, bodyLines: string[]}} parsed
+ * output of parsePatchEmail() for this patch
+ * @param {Record<string, {text: string}>} comments locator -> comment
+ * @returns {string} reply body ("" when there are no comments)
+ */
+export function formatReview(parsed, comments) {
+ const entries = Object.entries(comments || {});
+ if (entries.length === 0) {
+ return "";
+ }
+
+ const out = [];
+
+ const general = comments[GENERAL];
+ if (general) {
+ out.push(general.text, "");
+ }
+
+ // Group line comments by file/hunk, in diff order.
+ const byHunk = new Map(); // "f:h" -> [{lineIndex, text}]
+ for (const [loc, comment] of entries) {
+ if (loc === GENERAL) {
+ continue;
+ }
+ const { fileIndex, hunkIndex, lineIndex } = parseLocator(loc);
+ const k = `${fileIndex}:${hunkIndex}`;
+ if (!byHunk.has(k)) {
+ byHunk.set(k, []);
+ }
+ byHunk.get(k).push({ fileIndex, hunkIndex, lineIndex, text: comment.text });
+ }
+
+ const orderedHunks = [...byHunk.values()]
+ .map((list) => list.sort((a, b) => a.lineIndex - b.lineIndex))
+ .sort((a, b) => a[0].fileIndex - b[0].fileIndex || a[0].hunkIndex - b[0].hunkIndex);
+
+ let lastFileIndex = -1;
+ for (const hunkComments of orderedHunks) {
+ const { fileIndex, hunkIndex } = hunkComments[0];
+ const file = parsed.files[fileIndex];
+ const hunk = file && file.hunks[hunkIndex];
+ if (!hunk) {
+ continue;
+ }
+
+ if (fileIndex !== lastFileIndex) {
+ out.push(`> diff --git a/${file.oldPath} b/${file.newPath}`);
+ lastFileIndex = fileIndex;
+ }
+ out.push(`> ${hunk.header}`);
+
+ // Quote from a little context above the first commented line through the
+ // last commented line, inserting comments after their targets.
+ const firstTarget = hunkComments[0].lineIndex;
+ const lastTarget = hunkComments[hunkComments.length - 1].lineIndex;
+ const start = Math.max(0, firstTarget - CONTEXT_BEFORE);
+ if (start > 0) {
+ out.push("> [...]");
+ }
+
+ let next = 0; // index into hunkComments
+ for (let i = start; i <= lastTarget && i < hunk.lines.length; i++) {
+ out.push(`> ${hunk.lines[i].raw}`);
+ while (next < hunkComments.length && hunkComments[next].lineIndex === i) {
+ out.push("", hunkComments[next].text, "");
+ next++;
+ }
+ }
+ out.push("");
+ }
+
+ while (out.length && out[out.length - 1] === "") {
+ out.pop();
+ }
+ out.push("");
+ return out.join("\n");
+}
+
+/** Comment count for a patch, general comment included. */
+export function commentCount(comments) {
+ return Object.keys(comments || {}).length;
+}
diff --git a/extension/modules/review-store.js b/extension/modules/review-store.js
@@ -0,0 +1,43 @@
+// Draft review comments, persisted in storage.local so they survive closing
+// the review tab. Keyed by the patch's Message-ID header (stable across
+// folders and Thunderbird restarts, unlike numeric message ids).
+
+const key = (headerMessageId) => `comments:${headerMessageId}`;
+
+// A locator addresses a diff line: `${fileIndex}:${hunkIndex}:${lineIndex}`,
+// or the string "general" for a whole-patch comment.
+export const GENERAL = "general";
+
+export function locator(fileIndex, hunkIndex, lineIndex) {
+ return `${fileIndex}:${hunkIndex}:${lineIndex}`;
+}
+
+export function parseLocator(loc) {
+ if (loc === GENERAL) {
+ return { general: true };
+ }
+ const [fileIndex, hunkIndex, lineIndex] = loc.split(":").map(Number);
+ return { general: false, fileIndex, hunkIndex, lineIndex };
+}
+
+/** @returns {Promise<Record<string, {text: string, updatedAt: number}>>} */
+export async function loadComments(headerMessageId) {
+ const k = key(headerMessageId);
+ const found = await messenger.storage.local.get(k);
+ return found[k] || {};
+}
+
+export async function saveComment(headerMessageId, loc, text) {
+ const comments = await loadComments(headerMessageId);
+ if (text && text.trim()) {
+ comments[loc] = { text: text.trim(), updatedAt: Date.now() };
+ } else {
+ delete comments[loc];
+ }
+ await messenger.storage.local.set({ [key(headerMessageId)]: comments });
+ return comments;
+}
+
+export async function clearComments(headerMessageId) {
+ await messenger.storage.local.remove(key(headerMessageId));
+}
diff --git a/extension/modules/series.js b/extension/modules/series.js
@@ -0,0 +1,123 @@
+// Collect a full patch series from one displayed message: find the sibling
+// patches in the same folder, order them, and fetch their plain-text bodies.
+
+import { parseSubject, sameSeries, bodyLooksLikeDiff } from "./patch-detect.js";
+
+/** Extract the text/plain body from a messages.getFull() part tree. */
+export function plainBodyFromPart(part) {
+ if (!part) {
+ return "";
+ }
+ const contentType = (part.contentType || "").toLowerCase();
+ if (contentType.startsWith("text/plain") && typeof part.body === "string") {
+ return part.body;
+ }
+ for (const sub of part.parts || []) {
+ const body = plainBodyFromPart(sub);
+ if (body) {
+ return body;
+ }
+ }
+ // Fall back to any textual body at all (some senders use text/x-patch).
+ if (typeof part.body === "string" && contentType.startsWith("text/")) {
+ return part.body;
+ }
+ return "";
+}
+
+export async function getPlainBody(messageId) {
+ const full = await messenger.messages.getFull(messageId);
+ return plainBodyFromPart(full);
+}
+
+/** Query all messages in the anchor's folder within a date window, following pagination. */
+async function listFolderMessages(anchor, windowDays) {
+ const from = new Date(anchor.date.getTime() - windowDays * 86400e3);
+ const to = new Date(anchor.date.getTime() + windowDays * 86400e3);
+ const base = { fromDate: from, toDate: to, subject: "PATCH" };
+
+ let page;
+ try {
+ // TB 121+ addressing; falls back to the MailFolder object on TB 115.
+ page = await messenger.messages.query({ ...base, folderId: anchor.folder.id });
+ } catch (e) {
+ page = await messenger.messages.query({ ...base, folder: anchor.folder });
+ }
+
+ const out = [...page.messages];
+ while (page.id) {
+ page = await messenger.messages.continueList(page.id);
+ out.push(...page.messages);
+ }
+ return out;
+}
+
+/**
+ * Build the ordered series for the given message header.
+ * @returns {Promise<Array<{
+ * id: number, headerMessageId: string, subject: string, author: string,
+ * date: number, n: number|null, m: number|null, isCover: boolean,
+ * title: string, body: string, hasDiff: boolean
+ * }>>} ordered patches (cover letter first when present)
+ */
+export async function collectSeries(anchor) {
+ const anchorInfo = parseSubject(anchor.subject);
+ const members = new Map(); // headerMessageId -> {header, info}
+
+ members.set(anchor.headerMessageId, { header: anchor, info: anchorInfo });
+
+ if (anchorInfo && anchorInfo.m !== null) {
+ let candidates = [];
+ try {
+ candidates = await listFolderMessages(anchor, 30);
+ } catch (e) {
+ console.warn("patch-review: folder query failed, reviewing single message", e);
+ }
+ for (const header of candidates) {
+ const info = parseSubject(header.subject);
+ if (!sameSeries(anchorInfo, info)) {
+ continue;
+ }
+ const existing = members.get(header.headerMessageId);
+ if (!existing) {
+ members.set(header.headerMessageId, { header, info });
+ }
+ }
+ }
+
+ // Keep one message per series index (a resend may duplicate an index).
+ const byIndex = new Map();
+ for (const entry of members.values()) {
+ const n = entry.info ? entry.info.n : null;
+ const key = n === null ? `id:${entry.header.headerMessageId}` : n;
+ const prev = byIndex.get(key);
+ if (!prev || entry.header.date > prev.header.date) {
+ byIndex.set(key, entry);
+ }
+ }
+
+ const ordered = [...byIndex.values()].sort((a, b) => {
+ const an = a.info && a.info.n !== null ? a.info.n : 0;
+ const bn = b.info && b.info.n !== null ? b.info.n : 0;
+ return an - bn || a.header.date - b.header.date;
+ });
+
+ const series = [];
+ for (const { header, info } of ordered) {
+ const body = await getPlainBody(header.id);
+ series.push({
+ id: header.id,
+ headerMessageId: header.headerMessageId,
+ subject: header.subject,
+ author: header.author,
+ date: header.date.getTime ? header.date.getTime() : header.date,
+ n: info ? info.n : null,
+ m: info ? info.m : null,
+ isCover: Boolean(info && info.n === 0),
+ title: info ? info.title : header.subject,
+ body,
+ hasDiff: bodyLooksLikeDiff(body),
+ });
+ }
+ return series;
+}
diff --git a/extension/modules/sourcehut.js b/extension/modules/sourcehut.js
@@ -0,0 +1,22 @@
+// Sourcehut (lists.sr.ht) email controls: replies to a patchset thread may
+// carry an X-Sourcehut-Patchset-Update header to change the patchset status.
+
+export const STATUS_HEADER = "X-Sourcehut-Patchset-Update";
+
+export const SOURCEHUT_STATUSES = [
+ "APPROVED",
+ "NEEDS_REVISION",
+ "REJECTED",
+ "PROPOSED",
+ "SUPERSEDED",
+ "APPLIED",
+];
+
+/**
+ * True when any of the given header values (List-Id, recipients, cc) points
+ * at a sourcehut mailing list. Matches both the address form
+ * "~user/list@lists.sr.ht" and the List-Id form "<~user/list.lists.sr.ht>".
+ */
+export function isSourcehutList(values) {
+ return (values || []).some((v) => v && /\blists\.sr\.ht\b/i.test(v));
+}
diff --git a/extension/options/options.html b/extension/options/options.html
@@ -0,0 +1,55 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <style>
+ body {
+ font: 13px/1.5 -apple-system, "Segoe UI", sans-serif;
+ margin: 1rem;
+ min-width: 34rem;
+ }
+ label { display: block; font-weight: 600; margin: 0.8rem 0 0.2rem; }
+ .hint { color: #656d76; font-weight: 400; font-size: 12px; }
+ input[type="text"], select {
+ width: 100%;
+ padding: 0.3rem 0.5rem;
+ box-sizing: border-box;
+ }
+ table { width: 100%; border-collapse: collapse; margin-top: 0.3rem; }
+ td { padding: 0.15rem; }
+ td.match { width: 40%; }
+ td.remove { width: 2rem; }
+ .row-buttons { margin-top: 0.6rem; display: flex; gap: 0.5rem; align-items: center; }
+ #saved { color: #1a7f37; margin-left: 0.5rem; }
+ </style>
+</head>
+<body>
+ <label for="editor">Editor command
+ <span class="hint">— run from the repository after applying, e.g. “code -n” or “emacsclient -n”.
+ Leave empty to use $EDITOR.</span>
+ </label>
+ <input id="editor" type="text" placeholder="$EDITOR" spellcheck="false">
+
+ <label for="strategy">Apply strategy</label>
+ <select id="strategy">
+ <option value="am3">git am --3way (recommended)</option>
+ <option value="am">git am</option>
+ </select>
+
+ <label>Repository mappings
+ <span class="hint">— when a patch's List-Id or sender contains the match string,
+ its repository path is filled in automatically.</span>
+ </label>
+ <table id="mappings">
+ <tbody></tbody>
+ </table>
+
+ <div class="row-buttons">
+ <button id="add">Add mapping</button>
+ <button id="save">Save</button>
+ <span id="saved" hidden>Saved.</span>
+ </div>
+
+ <script src="options.js"></script>
+</body>
+</html>
diff --git a/extension/options/options.js b/extension/options/options.js
@@ -0,0 +1,86 @@
+"use strict";
+
+const tbody = document.querySelector("#mappings tbody");
+
+function addRow(match = "", repo = "") {
+ const tr = document.createElement("tr");
+
+ const matchTd = document.createElement("td");
+ matchTd.className = "match";
+ const matchInput = document.createElement("input");
+ matchInput.type = "text";
+ matchInput.placeholder = "list-id or sender contains…";
+ matchInput.value = match;
+ matchTd.appendChild(matchInput);
+
+ const repoTd = document.createElement("td");
+ const repoInput = document.createElement("input");
+ repoInput.type = "text";
+ repoInput.placeholder = "/absolute/path/to/repo";
+ repoInput.value = repo;
+ repoTd.appendChild(repoInput);
+
+ const browseTd = document.createElement("td");
+ browseTd.className = "remove";
+ const browseBtn = document.createElement("button");
+ browseBtn.textContent = "…";
+ browseBtn.title = "Browse for the repository directory";
+ browseBtn.addEventListener("click", async () => {
+ const response = await messenger.runtime.sendMessage({
+ type: "pick-repo",
+ start: repoInput.value.trim(),
+ });
+ if (response && response.ok && response.output) {
+ repoInput.value = response.output.replace(/(.)\/$/, "$1");
+ }
+ });
+ browseTd.appendChild(browseBtn);
+
+ const removeTd = document.createElement("td");
+ removeTd.className = "remove";
+ const removeBtn = document.createElement("button");
+ removeBtn.textContent = "✕";
+ removeBtn.title = "Remove";
+ removeBtn.addEventListener("click", () => tr.remove());
+ removeTd.appendChild(removeBtn);
+
+ tr.append(matchTd, repoTd, browseTd, removeTd);
+ tbody.appendChild(tr);
+}
+
+async function load() {
+ const found = await messenger.storage.local.get("config");
+ const config = Object.assign({ editor: "", strategy: "am3", mappings: [] }, found.config);
+ document.querySelector("#editor").value = config.editor;
+ document.querySelector("#strategy").value = config.strategy;
+ for (const mapping of config.mappings) {
+ addRow(mapping.match, mapping.repo);
+ }
+ if (!config.mappings.length) {
+ addRow();
+ }
+}
+
+document.querySelector("#add").addEventListener("click", () => addRow());
+
+document.querySelector("#save").addEventListener("click", async () => {
+ const mappings = [...tbody.querySelectorAll("tr")]
+ .map((tr) => {
+ const [match, repo] = [...tr.querySelectorAll("input")].map((el) => el.value.trim());
+ return { match, repo };
+ })
+ .filter((mapping) => mapping.match && mapping.repo);
+
+ await messenger.storage.local.set({
+ config: {
+ editor: document.querySelector("#editor").value.trim(),
+ strategy: document.querySelector("#strategy").value,
+ mappings,
+ },
+ });
+ const saved = document.querySelector("#saved");
+ saved.hidden = false;
+ setTimeout(() => (saved.hidden = true), 1500);
+});
+
+load();
diff --git a/extension/review/review.css b/extension/review/review.css
@@ -0,0 +1,324 @@
+:root {
+ --bg: #ffffff;
+ --fg: #1f2328;
+ --muted: #656d76;
+ --border: #d1d9e0;
+ --surface: #f6f8fa;
+ --accent: #0969da;
+ --add-bg: #dafbe1;
+ --add-gutter: #aceebb;
+ --del-bg: #ffebe9;
+ --del-gutter: #ffcecb;
+ --hunk-bg: #ddf4ff;
+ --comment-bg: #fff8c5;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0d1117;
+ --fg: #e6edf3;
+ --muted: #8d96a0;
+ --border: #30363d;
+ --surface: #161b22;
+ --accent: #4493f8;
+ --add-bg: #12261e;
+ --add-gutter: #1b4721;
+ --del-bg: #25171c;
+ --del-gutter: #542426;
+ --hunk-bg: #121d2f;
+ --comment-bg: #272115;
+ }
+}
+
+* { box-sizing: border-box; }
+
+/* The hidden attribute must always win, even over display: flex rules. */
+[hidden] { display: none !important; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font: 13px/1.5 -apple-system, "Segoe UI", Cantarell, Ubuntu, sans-serif;
+}
+
+#topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.5rem 1rem;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+ position: sticky;
+ top: 0;
+ z-index: 2;
+}
+
+#series-title {
+ font-weight: 600;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+#actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+}
+
+#comment-count { color: var(--muted); }
+
+#repo-path {
+ width: 22rem;
+ max-width: 40vw;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--fg);
+ font-family: ui-monospace, monospace;
+}
+
+#srht-status {
+ display: flex;
+ align-items: center;
+ gap: 0.3rem;
+ color: var(--muted);
+ white-space: nowrap;
+}
+
+#srht-status select {
+ padding: 0.2rem 0.3rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--fg);
+ font: inherit;
+}
+
+button {
+ padding: 0.3rem 0.8rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--fg);
+ cursor: pointer;
+}
+
+button:hover { border-color: var(--accent); color: var(--accent); }
+button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
+button.primary:hover { color: #fff; opacity: 0.9; }
+
+#send-split {
+ position: relative;
+ display: flex;
+}
+
+#send-split #btn-send {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+#send-split #btn-send-menu {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ border-left: 1px solid rgba(255, 255, 255, 0.35);
+ padding: 0.3rem 0.45rem;
+}
+
+#send-menu {
+ position: absolute;
+ top: calc(100% + 4px);
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ min-width: 12rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ z-index: 4;
+ overflow: hidden;
+}
+
+#send-menu button {
+ border: 0;
+ border-radius: 0;
+ background: none;
+ text-align: left;
+ padding: 0.45rem 0.8rem;
+}
+
+#send-menu button:hover {
+ background: var(--hunk-bg);
+ color: var(--fg);
+}
+
+#series-nav {
+ display: flex;
+ gap: 0.4rem;
+ flex-wrap: wrap;
+ padding: 0.6rem 1rem;
+}
+
+#series-nav .pill {
+ padding: 0.2rem 0.7rem;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ cursor: pointer;
+ color: var(--muted);
+}
+
+#series-nav .pill.active {
+ border-color: var(--accent);
+ color: var(--accent);
+ font-weight: 600;
+}
+
+#series-nav .pill .badge {
+ display: inline-block;
+ min-width: 1.2em;
+ margin-left: 0.35em;
+ border-radius: 999px;
+ background: var(--accent);
+ color: #fff;
+ font-size: 11px;
+ text-align: center;
+}
+
+#content { padding: 0 1rem 4rem; max-width: 72rem; margin: 0 auto; }
+
+.commit-message {
+ position: relative;
+ margin: 0.5rem 0 1rem;
+ padding: 0.8rem 1rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ white-space: pre-wrap;
+ font-family: ui-monospace, monospace;
+}
+
+.commit-message .btn-general { position: absolute; top: 0.5rem; right: 0.5rem; }
+
+.file {
+ margin-bottom: 1rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.file > summary {
+ padding: 0.4rem 0.8rem;
+ background: var(--surface);
+ font-family: ui-monospace, monospace;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.file .file-note { padding: 0.4rem 0.8rem; color: var(--muted); }
+
+table.diff {
+ width: 100%;
+ border-collapse: collapse;
+ font: 12px/1.45 ui-monospace, "Cascadia Mono", "Source Code Pro", monospace;
+}
+
+table.diff td { padding: 0 0.5em; vertical-align: top; }
+
+td.gutter {
+ width: 3em;
+ min-width: 3em;
+ text-align: right;
+ color: var(--muted);
+ user-select: none;
+ border-right: 1px solid var(--border);
+}
+
+td.code { white-space: pre-wrap; word-break: break-all; width: 100%; }
+
+tr.hunk-header td {
+ background: var(--hunk-bg);
+ color: var(--muted);
+ padding: 0.2em 0.5em;
+}
+
+tr.line { cursor: pointer; }
+tr.line:hover td.code { box-shadow: inset 2px 0 0 var(--accent); }
+tr.line.add td { background: var(--add-bg); }
+tr.line.add td.gutter { background: var(--add-gutter); }
+tr.line.del td { background: var(--del-bg); }
+tr.line.del td.gutter { background: var(--del-gutter); }
+tr.line.commented td.code { box-shadow: inset 3px 0 0 var(--accent); }
+
+/* Intra-line highlight: the span that actually changed within a line. */
+tr.line.add td.code .hl { background: var(--add-gutter); border-radius: 2px; }
+tr.line.del td.code .hl { background: var(--del-gutter); border-radius: 2px; }
+
+tr.comment-row td { padding: 0; }
+
+.comment-box {
+ margin: 0.3rem 2rem 0.5rem 3.5rem;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--comment-bg);
+ font-family: -apple-system, "Segoe UI", sans-serif;
+}
+
+.comment-box .comment-text { padding: 0.5rem 0.8rem; white-space: pre-wrap; }
+
+.comment-box textarea {
+ display: block;
+ width: 100%;
+ min-height: 4.5em;
+ padding: 0.5rem 0.8rem;
+ border: 0;
+ border-bottom: 1px solid var(--border);
+ background: transparent;
+ color: var(--fg);
+ font: inherit;
+ resize: vertical;
+}
+
+.comment-box .comment-actions {
+ display: flex;
+ gap: 0.4rem;
+ justify-content: flex-end;
+ padding: 0.3rem 0.5rem;
+}
+
+#status {
+ position: fixed;
+ inset: auto 1rem 1rem auto;
+ width: min(44rem, 90vw);
+ max-height: 60vh;
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ z-index: 3;
+}
+
+#status-text {
+ margin: 0;
+ padding: 0.8rem 1rem;
+ overflow: auto;
+ font: 12px/1.45 ui-monospace, monospace;
+ white-space: pre-wrap;
+}
+
+#status-buttons {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+ padding: 0.5rem;
+ border-top: 1px solid var(--border);
+}
+
+.error { color: #d1242f; }
+@media (prefers-color-scheme: dark) { .error { color: #ff7b72; } }
diff --git a/extension/review/review.html b/extension/review/review.html
@@ -0,0 +1,47 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Patch review</title>
+ <link rel="stylesheet" href="review.css">
+</head>
+<body>
+ <header id="topbar">
+ <div id="series-title">Loading…</div>
+ <div id="actions">
+ <span id="comment-count" title="Draft comments">0 comments</span>
+ <input id="repo-path" type="text" placeholder="repository path" spellcheck="false">
+ <button id="btn-browse" title="Choose the repository directory">Browse…</button>
+ <button id="btn-apply" title="git am the series into the repository and open your editor">Apply series</button>
+ <label id="srht-status" hidden
+ title="Sourcehut list detected: add an X-Sourcehut-Patchset-Update header to the review">
+ <input id="srht-enable" type="checkbox">
+ set status
+ <select id="srht-select"></select>
+ </label>
+ <div id="send-split">
+ <button id="btn-send" class="primary" title="Send one reply per commented patch">Send review</button>
+ <button id="btn-send-menu" class="primary" title="Send options">▾</button>
+ <div id="send-menu" hidden>
+ <button data-mode="send">Send now</button>
+ <button data-mode="preview">Preview in compose…</button>
+ </div>
+ </div>
+ </div>
+ </header>
+
+ <nav id="series-nav"></nav>
+
+ <main id="content"></main>
+
+ <div id="status" hidden>
+ <pre id="status-text"></pre>
+ <div id="status-buttons">
+ <button id="btn-open-editor" hidden>Open editor</button>
+ <button id="btn-status-close">Close</button>
+ </div>
+ </div>
+
+ <script type="module" src="review.js"></script>
+</body>
+</html>
diff --git a/extension/review/review.js b/extension/review/review.js
@@ -0,0 +1,469 @@
+// Review tab: renders a patch series with inline, hunk-level commenting.
+
+import { parsePatchEmail } from "../modules/diff-parse.js";
+import { computeIntraline } from "../modules/intraline.js";
+import { formatReview } from "../modules/reply-format.js";
+import * as store from "../modules/review-store.js";
+import { SOURCEHUT_STATUSES } from "../modules/sourcehut.js";
+
+const $ = (sel) => document.querySelector(sel);
+
+const state = {
+ series: [], // from background: get-series
+ parsed: [], // parsePatchEmail per series entry
+ comments: [], // locator -> comment, per series entry
+ current: 0,
+ repo: "",
+};
+
+async function bg(request) {
+ const response = await messenger.runtime.sendMessage(request);
+ if (!response) {
+ throw new Error("no response from background script");
+ }
+ return response;
+}
+
+// ---------------------------------------------------------------------------
+// Boot
+
+async function init() {
+ const messageId = Number(new URLSearchParams(location.search).get("mid"));
+ const response = await bg({ type: "get-series", messageId });
+ if (!response.ok) {
+ $("#series-title").textContent = "Failed to load series";
+ showStatus(response.error || "unknown error", { error: true });
+ return;
+ }
+
+ state.series = response.series;
+ state.repo = response.repo || "";
+ state.parsed = state.series.map((p) => parsePatchEmail(p.body));
+ state.comments = await Promise.all(
+ state.series.map((p) => store.loadComments(p.headerMessageId))
+ );
+ state.current = Math.max(0, state.series.findIndex((p) => !p.isCover));
+
+ const anchor = state.series.find((p) => !p.isCover) || state.series[0];
+ const m = anchor.m ? ` (${state.series.filter((p) => !p.isCover).length}/${anchor.m} patches)` : "";
+ $("#series-title").textContent = anchor.title + m;
+ document.title = `Review: ${anchor.title}`;
+ $("#repo-path").value = state.repo;
+
+ if (response.isSourcehut) {
+ const select = $("#srht-select");
+ for (const status of SOURCEHUT_STATUSES) {
+ const option = document.createElement("option");
+ option.value = status;
+ option.textContent = status;
+ select.appendChild(option);
+ }
+ $("#srht-status").hidden = false;
+ }
+
+ renderNav();
+ renderPatch();
+ updateCount();
+}
+
+// ---------------------------------------------------------------------------
+// Rendering
+
+function renderNav() {
+ const nav = $("#series-nav");
+ nav.textContent = "";
+ state.series.forEach((patch, i) => {
+ const pill = document.createElement("span");
+ pill.className = "pill" + (i === state.current ? " active" : "");
+ pill.textContent = patch.isCover
+ ? "cover"
+ : patch.n !== null && patch.m
+ ? `${patch.n}/${patch.m}`
+ : "patch";
+ const count = Object.keys(state.comments[i]).length;
+ if (count) {
+ const badge = document.createElement("span");
+ badge.className = "badge";
+ badge.textContent = count;
+ pill.appendChild(badge);
+ }
+ pill.title = patch.subject;
+ pill.addEventListener("click", () => {
+ state.current = i;
+ renderNav();
+ renderPatch();
+ });
+ nav.appendChild(pill);
+ });
+}
+
+function renderPatch() {
+ const content = $("#content");
+ content.textContent = "";
+ const i = state.current;
+ const patch = state.series[i];
+ const parsed = state.parsed[i];
+
+ // Commit message (or cover letter text) with a whole-patch comment. Never
+ // fall back to the raw body for a patch — that would print the whole diff
+ // above the interactive one (common case: single-line commit messages,
+ // whose body starts directly at the "---" scissors).
+ const msg = document.createElement("div");
+ msg.className = "commit-message";
+ msg.textContent = patch.isCover
+ ? parsed.commitMessage || patch.body
+ : parsed.commitMessage || patch.title;
+ const generalBtn = document.createElement("button");
+ generalBtn.className = "btn-general";
+ generalBtn.textContent = state.comments[i][store.GENERAL] ? "Edit comment" : "Comment on patch";
+ generalBtn.addEventListener("click", () => openGeneralEditor(msg));
+ msg.appendChild(generalBtn);
+ content.appendChild(msg);
+
+ const existingGeneral = state.comments[i][store.GENERAL];
+ if (existingGeneral) {
+ content.appendChild(commentCard(existingGeneral.text, () => openGeneralEditor(msg)));
+ }
+
+ parsed.files.forEach((file, fileIndex) => {
+ const details = document.createElement("details");
+ details.className = "file";
+ details.open = true;
+
+ const summary = document.createElement("summary");
+ summary.textContent = fileLabel(file);
+ details.appendChild(summary);
+
+ if (file.isBinary) {
+ const note = document.createElement("div");
+ note.className = "file-note";
+ note.textContent = "Binary file — not reviewable inline.";
+ details.appendChild(note);
+ } else {
+ details.appendChild(renderFileTable(file, fileIndex));
+ }
+ content.appendChild(details);
+ });
+
+ if (!parsed.files.length && !patch.isCover) {
+ const note = document.createElement("div");
+ note.className = "file-note";
+ note.textContent = "No diff found in this message.";
+ content.appendChild(note);
+ }
+}
+
+function fileLabel(file) {
+ if (file.isRename) return `${file.oldPath} → ${file.newPath}`;
+ if (file.isNew) return `${file.displayPath} (new file)`;
+ if (file.isDeleted) return `${file.oldPath} (deleted)`;
+ return file.displayPath;
+}
+
+function renderFileTable(file, fileIndex) {
+ const table = document.createElement("table");
+ table.className = "diff";
+ const tbody = document.createElement("tbody");
+ table.appendChild(tbody);
+
+ file.hunks.forEach((hunk, hunkIndex) => {
+ const headerRow = document.createElement("tr");
+ headerRow.className = "hunk-header";
+ const headerCell = document.createElement("td");
+ headerCell.colSpan = 3;
+ headerCell.textContent = hunk.header;
+ headerRow.appendChild(headerCell);
+ tbody.appendChild(headerRow);
+
+ const intraline = computeIntraline(hunk.lines);
+
+ hunk.lines.forEach((line, lineIndex) => {
+ const loc = store.locator(fileIndex, hunkIndex, lineIndex);
+ const row = document.createElement("tr");
+ row.className = `line ${line.origin}`;
+ row.dataset.loc = loc;
+
+ const oldGutter = document.createElement("td");
+ oldGutter.className = "gutter";
+ oldGutter.textContent = line.oldLine ?? "";
+ const newGutter = document.createElement("td");
+ newGutter.className = "gutter";
+ newGutter.textContent = line.newLine ?? "";
+ row.append(oldGutter, newGutter, codeCell(line, intraline[lineIndex]));
+ row.addEventListener("click", () => toggleEditor(row, loc));
+ tbody.appendChild(row);
+
+ const existing = state.comments[state.current][loc];
+ if (existing) {
+ row.classList.add("commented");
+ tbody.appendChild(commentRow(existing.text, row, loc));
+ }
+ });
+ });
+
+ return table;
+}
+
+/** Code cell, with the intra-line changed span wrapped for a stronger tint. */
+function codeCell(line, range) {
+ const td = document.createElement("td");
+ td.className = "code";
+ if (!range) {
+ td.textContent = line.raw || " ";
+ return td;
+ }
+ // range addresses line.text; raw carries the +/- prefix, hence the offset.
+ const start = range.start + 1;
+ const end = range.end + 1;
+ td.append(line.raw.slice(0, start));
+ const hl = document.createElement("span");
+ hl.className = "hl";
+ hl.textContent = line.raw.slice(start, end);
+ td.append(hl, line.raw.slice(end));
+ return td;
+}
+
+function commentCard(text, onEdit) {
+ const box = document.createElement("div");
+ box.className = "comment-box";
+ const body = document.createElement("div");
+ body.className = "comment-text";
+ body.textContent = text;
+ box.appendChild(body);
+ box.addEventListener("dblclick", onEdit);
+ box.title = "Double-click to edit";
+ return box;
+}
+
+function commentRow(text, lineRow, loc) {
+ const tr = document.createElement("tr");
+ tr.className = "comment-row";
+ const td = document.createElement("td");
+ td.colSpan = 3;
+ td.appendChild(commentCard(text, () => toggleEditor(lineRow, loc)));
+ tr.appendChild(td);
+ return tr;
+}
+
+// ---------------------------------------------------------------------------
+// Comment editing
+
+function toggleEditor(lineRow, loc) {
+ const open = lineRow.nextElementSibling;
+ if (open && open.classList.contains("editor-row")) {
+ open.remove();
+ return;
+ }
+ document.querySelectorAll(".editor-row").forEach((el) => el.remove());
+
+ const existing = state.comments[state.current][loc];
+ const tr = document.createElement("tr");
+ tr.className = "comment-row editor-row";
+ const td = document.createElement("td");
+ td.colSpan = 3;
+ td.appendChild(editorBox(existing ? existing.text : "", loc, () => tr.remove()));
+ tr.appendChild(td);
+ lineRow.after(tr);
+ tr.querySelector("textarea").focus();
+}
+
+function openGeneralEditor(anchorEl) {
+ document.querySelectorAll(".editor-row, .general-editor").forEach((el) => el.remove());
+ const existing = state.comments[state.current][store.GENERAL];
+ const box = editorBox(existing ? existing.text : "", store.GENERAL, () => box.remove());
+ box.classList.add("general-editor");
+ anchorEl.after(box);
+ box.querySelector("textarea").focus();
+}
+
+function editorBox(initial, loc, close) {
+ const box = document.createElement("div");
+ box.className = "comment-box";
+
+ const textarea = document.createElement("textarea");
+ textarea.value = initial;
+ textarea.placeholder = "Write a review comment… (Ctrl+Enter to save)";
+ box.appendChild(textarea);
+
+ const actions = document.createElement("div");
+ actions.className = "comment-actions";
+
+ const save = async () => {
+ const i = state.current;
+ state.comments[i] = await store.saveComment(
+ state.series[i].headerMessageId,
+ loc,
+ textarea.value
+ );
+ close();
+ renderNav();
+ renderPatch();
+ updateCount();
+ };
+
+ if (initial) {
+ const del = document.createElement("button");
+ del.textContent = "Delete";
+ del.addEventListener("click", async () => {
+ textarea.value = "";
+ await save();
+ });
+ actions.appendChild(del);
+ }
+
+ const cancel = document.createElement("button");
+ cancel.textContent = "Cancel";
+ cancel.addEventListener("click", close);
+ actions.appendChild(cancel);
+
+ const saveBtn = document.createElement("button");
+ saveBtn.className = "primary";
+ saveBtn.textContent = "Save";
+ saveBtn.addEventListener("click", save);
+ actions.appendChild(saveBtn);
+
+ textarea.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+ save();
+ } else if (e.key === "Escape") {
+ close();
+ }
+ });
+
+ box.appendChild(actions);
+ return box;
+}
+
+function updateCount() {
+ const total = state.comments.reduce((sum, c) => sum + Object.keys(c).length, 0);
+ $("#comment-count").textContent = `${total} comment${total === 1 ? "" : "s"}`;
+}
+
+// ---------------------------------------------------------------------------
+// Send review / apply series
+
+async function sendReview(mode) {
+ const status =
+ !$("#srht-status").hidden && $("#srht-enable").checked ? $("#srht-select").value : null;
+
+ const replies = [];
+ state.series.forEach((patch, i) => {
+ const body = formatReview(state.parsed[i], state.comments[i]);
+ if (body) {
+ replies.push({ messageId: patch.id, body });
+ }
+ });
+ if (!replies.length) {
+ if (!status) {
+ showStatus("No comments yet — click a diff line to add one.");
+ return;
+ }
+ // Status-only review (e.g. APPROVED): reply to the cover letter when
+ // there is one (the series is ordered cover first), body left to you.
+ replies.push({ messageId: state.series[0].id, body: "" });
+ }
+
+ const response = await bg({ type: "send-review", mode, replies, status });
+ if (!response.ok) {
+ showStatus(response.error, { error: true });
+ return;
+ }
+ if (mode === "send") {
+ // The review went out: drop the now-sent draft comments and close up.
+ await Promise.all(state.series.map((p) => store.clearComments(p.headerMessageId)));
+ const tab = await messenger.tabs.getCurrent();
+ await messenger.tabs.remove(tab.id);
+ }
+}
+
+function closeSendMenu() {
+ $("#send-menu").hidden = true;
+}
+
+$("#btn-send").addEventListener("click", () => sendReview("send"));
+
+$("#btn-send-menu").addEventListener("click", (e) => {
+ e.stopPropagation();
+ $("#send-menu").hidden = !$("#send-menu").hidden;
+});
+
+document.querySelectorAll("#send-menu button").forEach((button) => {
+ button.addEventListener("click", () => {
+ closeSendMenu();
+ sendReview(button.dataset.mode);
+ });
+});
+
+document.addEventListener("click", (e) => {
+ if (!e.target.closest("#send-split")) {
+ closeSendMenu();
+ }
+});
+
+$("#btn-apply").addEventListener("click", async () => {
+ const repo = $("#repo-path").value.trim();
+ if (!repo) {
+ showStatus("Enter the path of the repository to apply the series to.", { error: true });
+ $("#repo-path").focus();
+ return;
+ }
+
+ const messageIds = state.series.filter((p) => !p.isCover && p.hasDiff).map((p) => p.id);
+ if (!messageIds.length) {
+ showStatus("Nothing to apply: no patch in this series contains a diff.", { error: true });
+ return;
+ }
+
+ showStatus("Applying series…");
+ const response = await bg({ type: "apply-series", messageIds, repo });
+ const text = [response.ok ? "Series applied." : "Apply failed.", response.output || response.error]
+ .filter(Boolean)
+ .join("\n\n");
+ showStatus(text, { error: !response.ok, editor: response.ok ? repo : null });
+});
+
+$("#btn-browse").addEventListener("click", async () => {
+ const response = await bg({ type: "pick-repo", start: $("#repo-path").value.trim() });
+ if (response.ok && response.output) {
+ // Some choosers (osascript) return a trailing slash; keep the field editable.
+ $("#repo-path").value = response.output.replace(/(.)\/$/, "$1");
+ } else if (!response.ok) {
+ showStatus(response.error || "No directory chooser available — type the path instead.", {
+ error: true,
+ });
+ }
+});
+
+$("#btn-open-editor").addEventListener("click", async () => {
+ const repo = $("#repo-path").value.trim();
+ const files = [...new Set(
+ state.parsed.flatMap((p) => p.files.map((f) => f.displayPath)).filter(Boolean)
+ )];
+ const response = await bg({ type: "open-editor", repo, files });
+ if (!response.ok) {
+ showStatus(response.error || response.output || "Could not open the editor.", { error: true });
+ } else {
+ hideStatus();
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Status panel
+
+function showStatus(text, { error = false, editor = null } = {}) {
+ $("#status-text").textContent = text;
+ $("#status-text").classList.toggle("error", error);
+ $("#btn-open-editor").hidden = !editor;
+ $("#status").hidden = false;
+}
+
+function hideStatus() {
+ $("#status").hidden = true;
+}
+
+$("#btn-status-close").addEventListener("click", hideStatus);
+
+init().catch((e) => {
+ $("#series-title").textContent = "Error";
+ showStatus(e.message || String(e), { error: true });
+});
diff --git a/host/host-manifest.json.in b/host/host-manifest.json.in
@@ -0,0 +1,7 @@
+{
+ "name": "email.coquand.patchreview",
+ "description": "Patch Review native host: applies patch series with git am and opens the editor",
+ "path": "@HOST_PATH@",
+ "type": "stdio",
+ "allowed_extensions": ["patchreview@coquand.email"]
+}
diff --git a/host/install-host.sh b/host/install-host.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+# Install the native messaging host manifest so Thunderbird can find
+# review-host.sh. POSIX sh only.
+set -eu
+
+dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+host="$dir/review-host.sh"
+name="email.coquand.patchreview"
+
+chmod +x "$host"
+
+install_manifest() {
+ mkdir -p "$1"
+ sed "s|@HOST_PATH@|$host|" "$dir/host-manifest.json.in" > "$1/$name.json"
+ echo "installed $1/$name.json"
+}
+
+# Thunderbird's native messaging search path differs across versions and
+# packagings; installing the (tiny) manifest in both candidates is harmless.
+case $(uname) in
+ Darwin)
+ install_manifest "$HOME/Library/Mozilla/NativeMessagingHosts"
+ install_manifest "$HOME/Library/Application Support/Mozilla/NativeMessagingHosts"
+ ;;
+ *)
+ install_manifest "$HOME/.mozilla/native-messaging-hosts"
+ install_manifest "$HOME/.thunderbird/native-messaging-hosts"
+ ;;
+esac
+
+echo "Restart Thunderbird for the host to be picked up."
diff --git a/host/review-host.sh b/host/review-host.sh
@@ -0,0 +1,235 @@
+#!/bin/sh
+# Native messaging host for the Patch Review Thunderbird extension.
+#
+# Strict POSIX sh and POSIX utilities only (dd, od, awk, sed, tr, wc), plus
+# git. No jq: the extension sends {"d":"<base64>"} where the payload is a
+# line-based envelope (see extension/modules/native.js), so JSON never needs
+# to be parsed here — only produced, which printf and awk can do safely.
+#
+# Handles exactly one request per invocation, which is how
+# runtime.sendNativeMessage() drives a host.
+#
+# Intentional idioms shellcheck dislikes: single-quoted awk programs (SC2016),
+# two-stage printf for binary framing (SC2059), unquoted expansions used for
+# word splitting (SC2046/SC2086), trap-invoked cleanup (SC2329).
+# shellcheck disable=SC2016,SC2059,SC2046,SC2086,SC2329
+
+set -u
+LC_ALL=C
+export LC_ALL
+
+VERSION="0.1.0"
+
+# Native messaging protocol: stdout must carry nothing but framed responses.
+# Reserve fd 3 for responses and point stdout at stderr for everything else.
+exec 3>&1 1>&2
+
+# --- workspace --------------------------------------------------------------
+
+WORK=""
+cleanup() { [ -n "$WORK" ] && rm -rf "$WORK"; }
+trap cleanup EXIT INT TERM
+
+i=0
+while :; do
+ WORK="${TMPDIR:-/tmp}/patch-review.$$.$i"
+ if mkdir -m 700 "$WORK" 2>/dev/null; then
+ break
+ fi
+ i=$((i + 1))
+ if [ "$i" -gt 100 ]; then
+ echo "patch-review host: cannot create workspace" >&2
+ exit 1
+ fi
+done
+
+# --- awk helpers ------------------------------------------------------------
+
+AWK_B64DECODE='
+BEGIN {
+ chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+ for (i = 1; i <= 64; i++) val[substr(chars, i, 1)] = i - 1
+}
+{
+ line = $0
+ gsub(/=+$/, "", line)
+ n = length(line)
+ for (i = 1; i <= n; i += 4) {
+ chunk = substr(line, i, 4)
+ len = length(chunk)
+ acc = val[substr(chunk, 1, 1)] * 262144 + val[substr(chunk, 2, 1)] * 4096
+ if (len >= 3) acc += val[substr(chunk, 3, 1)] * 64
+ if (len >= 4) acc += val[substr(chunk, 4, 1)]
+ printf "%c", int(acc / 65536) % 256
+ if (len >= 3) printf "%c", int(acc / 256) % 256
+ if (len >= 4) printf "%c", acc % 256
+ }
+}'
+
+AWK_JSON_ESCAPE='
+BEGIN { first = 1 }
+{
+ gsub(/\\/, "\\\\")
+ gsub(/"/, "\\\"")
+ gsub("\t", "\\t")
+ if (!first) printf "\\n"
+ printf "%s", $0
+ first = 0
+}'
+
+b64decode() { awk "$AWK_B64DECODE"; }
+
+json_escape() {
+ # Drop control characters except tab and newline, then escape for JSON.
+ tr -d '\000-\010\013-\037' | awk "$AWK_JSON_ESCAPE"
+}
+
+# --- framing ----------------------------------------------------------------
+
+respond() {
+ n=${#1}
+ # Inner printf renders the four little-endian length bytes as octal
+ # escape text; the outer printf turns them into real bytes (NUL included,
+ # which never survives command substitution, hence the two stages).
+ printf "$(printf '\\%03o\\%03o\\%03o\\%03o' \
+ $((n % 256)) $((n / 256 % 256)) $((n / 65536 % 256)) $((n / 16777216 % 256)))" >&3
+ printf %s "$1" >&3
+}
+
+fail() {
+ respond "{\"ok\":false,\"error\":\"$(printf %s "$1" | json_escape)\"}"
+ exit 0
+}
+
+# Read the 4-byte little-endian length header. bs=1 makes dd's reads exact.
+set -- $(dd bs=1 count=4 2>/dev/null | od -An -tu1)
+if [ $# -ne 4 ]; then
+ exit 0 # closed without a request
+fi
+LEN=$(($1 + $2 * 256 + $3 * 65536 + $4 * 16777216))
+if [ "$LEN" -le 0 ] || [ "$LEN" -gt 67108864 ]; then
+ fail "implausible message length: $LEN"
+fi
+
+# Read exactly LEN payload bytes; a pipe may deliver them in pieces.
+: > "$WORK/payload"
+got=0
+while [ "$got" -lt "$LEN" ]; do
+ dd bs=$((LEN - got)) count=1 2>/dev/null >> "$WORK/payload"
+ now=$(wc -c < "$WORK/payload")
+ if [ "$now" -eq "$got" ]; then
+ fail "unexpected EOF while reading request"
+ fi
+ got=$now
+done
+
+# --- unwrap the envelope ----------------------------------------------------
+
+case $(dd bs=6 count=1 if="$WORK/payload" 2>/dev/null) in
+ '{"d":"') ;;
+ *) fail "unrecognized request format" ;;
+esac
+
+sed 's/^{"d":"//; s/"}$//' "$WORK/payload" | b64decode > "$WORK/envelope"
+
+ACTION="" REPO="" EDITOR_CMD="" FILES="" STRATEGY="am3" PATCHES=0
+
+while IFS=' ' read -r keyword value || [ -n "$keyword" ]; do
+ case $keyword in
+ action) ACTION=$value ;;
+ repo64) REPO=$(printf %s "$value" | b64decode) ;;
+ editor64) EDITOR_CMD=$(printf %s "$value" | b64decode) ;;
+ files64) FILES=$(printf %s "$value" | b64decode) ;;
+ strategy64) STRATEGY=$(printf %s "$value" | b64decode) ;;
+ patch64)
+ PATCHES=$((PATCHES + 1))
+ printf %s "$value" | b64decode > "$WORK/$(printf %04d "$PATCHES").patch"
+ ;;
+ end) break ;;
+ esac
+done < "$WORK/envelope"
+
+# --- actions ----------------------------------------------------------------
+
+require_repo() {
+ if [ -z "$REPO" ]; then
+ fail "no repository path given"
+ fi
+ if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ fail "not a git repository: $REPO"
+ fi
+}
+
+case $ACTION in
+ ping)
+ respond "{\"ok\":true,\"host\":\"patch-review\",\"version\":\"$VERSION\"}"
+ ;;
+
+ apply)
+ require_repo
+ if [ "$PATCHES" -eq 0 ]; then
+ fail "no patches in request"
+ fi
+ am_flags="--3way"
+ if [ "$STRATEGY" = "am" ]; then
+ am_flags=""
+ fi
+ if git -C "$REPO" am $am_flags "$WORK"/*.patch > "$WORK/log" 2>&1; then
+ respond "{\"ok\":true,\"output\":\"$(json_escape < "$WORK/log")\"}"
+ else
+ git -C "$REPO" am --abort >> "$WORK/log" 2>&1
+ echo "(git am --abort ran; the repository is back to its previous state)" >> "$WORK/log"
+ respond "{\"ok\":false,\"output\":\"$(json_escape < "$WORK/log")\"}"
+ fi
+ ;;
+
+ pickdir)
+ start=${REPO:-${HOME:-/}}
+ if command -v zenity >/dev/null 2>&1; then
+ dir=$(zenity --file-selection --directory \
+ --title="Choose repository" --filename="$start/" 2>/dev/null) || dir=""
+ elif command -v kdialog >/dev/null 2>&1; then
+ dir=$(kdialog --getexistingdirectory "$start" 2>/dev/null) || dir=""
+ elif command -v osascript >/dev/null 2>&1; then
+ dir=$(osascript -e \
+ 'POSIX path of (choose folder with prompt "Choose repository")' \
+ 2>/dev/null) || dir=""
+ else
+ fail "no directory chooser found (install zenity or kdialog); type the path instead"
+ fi
+ if [ -z "$dir" ]; then
+ respond '{"ok":true,"output":"","cancelled":true}'
+ else
+ respond "{\"ok\":true,\"output\":\"$(printf %s "$dir" | json_escape)\"}"
+ fi
+ ;;
+
+ edit)
+ require_repo
+ if [ -z "$EDITOR_CMD" ]; then
+ EDITOR_CMD=${EDITOR:-}
+ fi
+ if [ -z "$EDITOR_CMD" ]; then
+ EDITOR_CMD="xdg-open ."
+ FILES=""
+ fi
+ # Split the newline-separated file list without globbing.
+ set -f
+ oldifs=$IFS
+ IFS='
+'
+ set -- $FILES
+ IFS=$oldifs
+ set +f
+ # Detach: the editor must outlive this host process.
+ ( cd "$REPO" && nohup sh -c "$EDITOR_CMD \"\$@\"" patch-review-edit "$@" \
+ >/dev/null 2>&1 & )
+ respond "{\"ok\":true,\"output\":\"launched: $(printf %s "$EDITOR_CMD" | json_escape)\"}"
+ ;;
+
+ *)
+ fail "unknown action: $ACTION"
+ ;;
+esac
+
+exit 0
diff --git a/patch-review.1 b/patch-review.1
@@ -0,0 +1,169 @@
+.TH PATCH\-REVIEW 1 "July 2026" "patch-review 0.1.0" "Thunderbird Patch Review"
+.SH NAME
+patch\-review \- review git patches from email inside Thunderbird
+.SH SYNOPSIS
+A Thunderbird MailExtension plus a native messaging host.
+Open a patch email, press
+.BR Review ,
+comment on hunks, send the review as a mailing-list reply, and apply the
+series to a local repository with
+.BR git\-am (1).
+.SH DESCRIPTION
+.B patch\-review
+brings a review interface to git-by-email workflows
+.RB ( git\-send\-email (1)
+style).
+When a displayed message looks like a patch \(em a
+.B [PATCH]
+subject tag or a unified diff in the body \(em the
+.B Review
+button in the message header becomes active.
+It opens a dedicated review tab that shows the whole series:
+cover letter, per-patch commit messages, and every file and hunk rendered
+as a colored diff.
+Within replaced lines, the span that actually changed gets a stronger
+highlight, computed from the common prefix and suffix of each
+deleted/added line pair.
+.PP
+Clicking a diff line attaches an inline comment to it; comments are saved
+as drafts (keyed by Message-ID) and survive restarting Thunderbird.
+.B Send review
+turns the comments of each commented patch into one plain-text reply-to-all,
+quoting the relevant hunks kernel-style with the comments interleaved
+unquoted below the lines they target, and sends the replies immediately;
+the draft comments are then cleared and the review tab closes.
+The attached drop-down offers
+.I "Preview in compose"
+instead, which leaves the reply drafts open in compose windows.
+A reply that fails to send also stays open, so nothing is lost.
+.B Apply series
+hands the raw patch messages to the native host, which runs
+.B git am \-\-3way
+in the configured repository and can then launch your editor.
+On conflict the host runs
+.B git am \-\-abort
+and reports the output, leaving the repository untouched.
+.SH INSTALLATION
+.TP
+1. Native host
+Run
+.B host/install\-host.sh
+once.
+It installs the native messaging manifest into the per-user Mozilla and
+Thunderbird locations.
+The host itself is strict POSIX
+.BR sh (1)
+and needs only POSIX utilities plus
+.BR git (1).
+.TP
+2. Extension (temporary, for development)
+In Thunderbird open
+.IR "Tools \(-> Developer Tools \(-> Debug Add\-ons" ,
+choose
+.IR "Load Temporary Add\-on\|.\|.\|." ,
+and pick
+.BR extension/manifest.json .
+.TP
+3. Extension (permanent)
+Zip the
+.I extension/
+directory contents (manifest.json at the archive root), rename the archive to
+.BR patch\-review.xpi ,
+and install it from the Add-ons Manager gear menu.
+.PP
+Restart Thunderbird after installing the host so it is picked up.
+.SH CONFIGURATION
+Open the extension's preferences:
+.TP
+.B Editor command
+Run from the repository after a successful apply, with the changed files as
+arguments.
+Use a non-blocking GUI-capable command such as
+.B code \-n
+or
+.BR "emacsclient \-n" ;
+a bare terminal editor has no terminal to open in.
+Empty means
+.BR $EDITOR .
+.TP
+.B Apply strategy
+.B git am \-\-3way
+(default) or plain
+.BR "git am" .
+.TP
+.B Repository mappings
+Substring matches against a patch's List-Id header or sender, mapped to an
+absolute repository path.
+When no mapping matches, the last path a series was applied to is used;
+it is remembered until changed.
+.PP
+Repository paths can be chosen with the
+.B Browse
+button, which opens a native directory chooser through the host
+.RB ( zenity (1),
+.BR kdialog (1),
+or
+.BR osascript (1),
+whichever is found); the path field stays editable either way.
+.SH SOURCEHUT INTEGRATION
+When the series arrives via a sourcehut mailing list (a
+.B lists.sr.ht
+address in the List-Id or recipients), the review tab shows a
+.B "set status"
+checkbox with the patchset states sourcehut understands:
+APPROVED, NEEDS_REVISION, REJECTED, PROPOSED, SUPERSEDED, APPLIED.
+When enabled, the first reply of the review carries an
+.B X\-Sourcehut\-Patchset\-Update
+header with the chosen status, which sourcehut applies to the patchset.
+A status can also be sent without any line comments \(em
+.B Send review
+then sends a single, empty reply to the cover letter (or first patch)
+carrying just the header.
+.SH REVIEW WORKFLOW
+.nf
+1. Open any message of the series; press Review.
+2. The tab collects the sibling patches from the same folder
+ (same series tag, version, and patch count).
+3. Click lines, write comments; Ctrl+Enter saves.
+ "Comment on patch" attaches a general remark instead.
+4. Send review: one reply per commented patch, sent
+ immediately (or use the drop-down to preview first).
+5. Apply series: git am into the mapped repository,
+ then Open editor on the touched files.
+.fi
+.SH FILES
+.TP
+.I extension/
+The MailExtension (manifest v2, vanilla ES modules, no build step).
+.TP
+.I host/review\-host.sh
+Native messaging host.
+Speaks the length-prefixed framing with
+.BR dd (1)
+and
+.BR od (1),
+decodes the base64 envelope with
+.BR awk (1),
+and never parses JSON.
+.TP
+.I ~/.mozilla/native\-messaging\-hosts/email.coquand.patchreview.json
+Installed host manifest (also under
+.IR ~/.thunderbird ).
+.TP
+.I tests/
+.B node tests/run.mjs
+exercises the pure modules (subject parsing, diff parsing, reply
+formatting) against fixtures generated by
+.BR git\-format\-patch (1);
+.B sh tests/host\-test.sh
+drives the host end-to-end, including a conflicting apply.
+Regenerate fixtures with
+.BR tests/gen\-fixtures.sh .
+.SH CAVEATS
+Patches arriving as attachments, HTML-only mail, and reviewing from a git
+worktree preview are not supported yet.
+Series collection searches the folder of the opened message only.
+.SH AUTHOR
+Marc Coquand
+.MT marc@coquand.email
+.ME
diff --git a/pre-commit.sh b/pre-commit.sh
@@ -0,0 +1,5 @@
+#!/bin/sh
+# Add to your hooks
+# ln -sf ../../pre-commit.sh .git/hooks/pre-commit
+man -l patch-review.1 | col -b > README
+git add README
diff --git a/tests/fixtures/cover.body b/tests/fixtures/cover.body
@@ -0,0 +1,17 @@
+*** BLURB HERE ***
+
+Dev Example (3):
+ socket: validate file descriptors
+ log: add error logging helper
+ assets: add icon
+
+ icon.bin | Bin 0 -> 12 bytes
+ src/log.c | 6 ++++++
+ src/socket.c | 7 ++++++-
+ 3 files changed, 12 insertions(+), 1 deletion(-)
+ create mode 100644 icon.bin
+ create mode 100644 src/log.c
+
+--
+2.55.0
+
diff --git a/tests/fixtures/patch1.body b/tests/fixtures/patch1.body
@@ -0,0 +1,35 @@
+Reject negative descriptors in both open and close paths and
+report EBADF like the rest of the subsystem does.
+---
+ src/socket.c | 7 ++++++-
+ 1 file changed, 6 insertions(+), 1 deletion(-)
+
+diff --git a/src/socket.c b/src/socket.c
+index ceba5c8..6eab6c5 100644
+--- a/src/socket.c
++++ b/src/socket.c
+@@ -1,15 +1,20 @@
+ #include <stdio.h>
++#include <errno.h>
+
+ int open_socket(int fd)
+ {
+- if (fd < 0)
++ if (fd < 0) {
++ errno = EBADF;
+ return -1;
++ }
+ printf("opening %d\n", fd);
+ return fd;
+ }
+
+ int close_socket(int fd)
+ {
++ if (fd < 0)
++ return -1;
+ printf("closing %d\n", fd);
+ return 0;
+ }
+--
+2.55.0
+
diff --git a/tests/fixtures/patch2.body b/tests/fixtures/patch2.body
@@ -0,0 +1,20 @@
+---
+ src/log.c | 6 ++++++
+ 1 file changed, 6 insertions(+)
+ create mode 100644 src/log.c
+
+diff --git a/src/log.c b/src/log.c
+new file mode 100644
+index 0000000..52ca3a1
+--- /dev/null
++++ b/src/log.c
+@@ -0,0 +1,6 @@
++#include <stdio.h>
++
++void log_err(const char *msg)
++{
++ fprintf(stderr, "error: %s\n", msg);
++}
+--
+2.55.0
+
diff --git a/tests/fixtures/patch3.body b/tests/fixtures/patch3.body
@@ -0,0 +1,18 @@
+---
+ icon.bin | Bin 0 -> 12 bytes
+ 1 file changed, 0 insertions(+), 0 deletions(-)
+ create mode 100644 icon.bin
+
+diff --git a/icon.bin b/icon.bin
+new file mode 100644
+index 0000000000000000000000000000000000000000..6735744d9c5bfa205ec44c128ac9007f124c6686
+GIT binary patch
+literal 12
+TcmeAS@N?(olHy`uWMT#Y5cvVH
+
+literal 0
+HcmV?d00001
+
+--
+2.55.0
+
diff --git a/tests/gen-fixtures.sh b/tests/gen-fixtures.sh
@@ -0,0 +1,98 @@
+#!/bin/sh
+# Regenerate tests/fixtures from a real git-format-patch run. POSIX sh only.
+set -eu
+
+dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+out="$dir/fixtures"
+work="${TMPDIR:-/tmp}/patch-review-fixtures.$$"
+trap 'rm -rf "$work"' EXIT INT TERM
+mkdir -p "$work" "$out"
+
+repo="$work/repo"
+git init -q "$repo"
+git -C "$repo" config user.email dev@example.org
+git -C "$repo" config user.name "Dev Example"
+git -C "$repo" config commit.gpgsign false
+
+# Base commit (not part of the series).
+mkdir -p "$repo/src"
+cat > "$repo/src/socket.c" <<'EOF'
+#include <stdio.h>
+
+int open_socket(int fd)
+{
+ if (fd < 0)
+ return -1;
+ printf("opening %d\n", fd);
+ return fd;
+}
+
+int close_socket(int fd)
+{
+ printf("closing %d\n", fd);
+ return 0;
+}
+EOF
+git -C "$repo" add .
+git -C "$repo" commit -q -m "initial import"
+
+# Patch 1/3: modify two spots in one file (two hunks).
+cat > "$repo/src/socket.c" <<'EOF'
+#include <stdio.h>
+#include <errno.h>
+
+int open_socket(int fd)
+{
+ if (fd < 0) {
+ errno = EBADF;
+ return -1;
+ }
+ printf("opening %d\n", fd);
+ return fd;
+}
+
+int close_socket(int fd)
+{
+ if (fd < 0)
+ return -1;
+ printf("closing %d\n", fd);
+ return 0;
+}
+EOF
+git -C "$repo" add .
+git -C "$repo" commit -q -m "socket: validate file descriptors
+
+Reject negative descriptors in both open and close paths and
+report EBADF like the rest of the subsystem does."
+
+# Patch 2/3: add a new file.
+cat > "$repo/src/log.c" <<'EOF'
+#include <stdio.h>
+
+void log_err(const char *msg)
+{
+ fprintf(stderr, "error: %s\n", msg);
+}
+EOF
+git -C "$repo" add .
+git -C "$repo" commit -q -m "log: add error logging helper"
+
+# Patch 3/3: binary file.
+printf '\211PNG\r\n\032\n\000\001\002\003' > "$repo/icon.bin"
+git -C "$repo" add .
+git -C "$repo" commit -q -m "assets: add icon"
+
+git -C "$repo" format-patch -q --cover-letter -v2 -3 -o "$work/patches"
+
+# Fixtures hold the email *body* (what messages.getFull yields): strip the
+# RFC822 headers, i.e. everything through the first blank line.
+n=0
+for patch in "$work/patches"/*; do
+ case $patch in
+ *0000*) name=cover ;;
+ *) n=$((n + 1)); name="patch$n" ;;
+ esac
+ sed '1,/^$/d' "$patch" > "$out/$name.body"
+done
+
+ls "$out"
diff --git a/tests/host-test.sh b/tests/host-test.sh
@@ -0,0 +1,244 @@
+#!/bin/sh
+# End-to-end tests for host/review-host.sh, speaking the real native
+# messaging framing. POSIX sh + POSIX utilities only:
+# sh tests/host-test.sh
+#
+# Intentional idioms shellcheck dislikes: single-quoted awk programs, the
+# binary framing printf, od word splitting, and $? of test conditions.
+# shellcheck disable=SC2016,SC2059,SC2046,SC2319
+set -u
+LC_ALL=C
+export LC_ALL
+
+dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
+host="$dir/../host/review-host.sh"
+work="${TMPDIR:-/tmp}/patch-review-hosttest.$$"
+trap 'rm -rf "$work"' EXIT INT TERM
+mkdir -p "$work"
+
+failures=0
+check() { # $1 name, $2 = 0/1 pass
+ if [ "$2" -eq 0 ]; then
+ echo "ok $1"
+ else
+ failures=$((failures + 1))
+ echo "FAIL $1" >&2
+ fi
+}
+
+# --- base64 encoder (single line, no wrapping) ------------------------------
+
+AWK_B64ENCODE='
+BEGIN {
+ chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+ for (i = 0; i < 256; i++) ord[sprintf("%c", i)] = i
+ nbuf = 0
+}
+function put(b) {
+ buf[nbuf++] = b
+ if (nbuf == 3) flush3()
+}
+function flush3() {
+ acc = buf[0] * 65536 + buf[1] * 256 + buf[2]
+ printf "%s%s%s%s", substr(chars, int(acc / 262144) + 1, 1), \
+ substr(chars, int(acc / 4096) % 64 + 1, 1), \
+ substr(chars, int(acc / 64) % 64 + 1, 1), \
+ substr(chars, acc % 64 + 1, 1)
+ nbuf = 0
+}
+{
+ n = length($0)
+ for (i = 1; i <= n; i++) put(ord[substr($0, i, 1)])
+ put(10) # the newline awk stripped
+}
+END {
+ if (nbuf == 1) {
+ acc = buf[0] * 65536
+ printf "%s%s==", substr(chars, int(acc / 262144) + 1, 1), \
+ substr(chars, int(acc / 4096) % 64 + 1, 1)
+ } else if (nbuf == 2) {
+ acc = buf[0] * 65536 + buf[1] * 256
+ printf "%s%s%s=", substr(chars, int(acc / 262144) + 1, 1), \
+ substr(chars, int(acc / 4096) % 64 + 1, 1), \
+ substr(chars, int(acc / 64) % 64 + 1, 1)
+ }
+}'
+
+b64encode() { awk "$AWK_B64ENCODE"; }
+
+# --- framing ----------------------------------------------------------------
+
+send_request() { # $1 = envelope file; framed request on stdout
+ json="{\"d\":\"$(b64encode < "$1")\"}"
+ n=${#json}
+ printf "$(printf '\\%03o\\%03o\\%03o\\%03o' \
+ $((n % 256)) $((n / 256 % 256)) $((n / 65536 % 256)) $((n / 16777216 % 256)))"
+ printf %s "$json"
+}
+
+run_host() { # $1 = envelope file, $2 = response output file; honors EXTRA_PATH
+ env_file=$1
+ resp_file=$2
+ send_request "$env_file" | PATH="${EXTRA_PATH:+$EXTRA_PATH:}$PATH" sh "$host" \
+ > "$resp_file.raw" 2> "$resp_file.err"
+ # set -- consumes the function arguments, hence the copies above.
+ set -- $(od -An -tu1 -N4 "$resp_file.raw")
+ if [ $# -ne 4 ]; then
+ echo "(no framed response)" > "$resp_file"
+ return 1
+ fi
+ expected=$(($1 + $2 * 256 + $3 * 65536 + $4 * 16777216))
+ dd if="$resp_file.raw" ibs=1 skip=4 2>/dev/null > "$resp_file"
+ actual=$(wc -c < "$resp_file")
+ [ "$actual" -eq "$expected" ]
+}
+
+# --- scratch repos ----------------------------------------------------------
+
+setup_repo() {
+ git -C "$1" config user.email t@example.org
+ git -C "$1" config user.name Tester
+ git -C "$1" config commit.gpgsign false
+}
+
+upstream="$work/upstream"
+git init -q "$upstream"
+setup_repo "$upstream"
+echo "one" > "$upstream/file.txt"
+git -C "$upstream" add file.txt
+git -C "$upstream" commit -q -m "initial"
+
+contributor="$work/contributor"
+git clone -q "$upstream" "$contributor"
+setup_repo "$contributor"
+echo "two" >> "$contributor/file.txt"
+git -C "$contributor" add file.txt
+git -C "$contributor" commit -q -m "add second line"
+git -C "$contributor" format-patch -q -1 -o "$work/patches"
+patch_file=$(ls "$work/patches"/0001-*)
+
+# --- tests ------------------------------------------------------------------
+
+# ping
+printf 'action ping\nend\n' > "$work/env-ping"
+run_host "$work/env-ping" "$work/resp-ping"
+check "host: ping framing" $?
+grep -q '"ok":true' "$work/resp-ping"
+check "host: ping ok" $?
+
+# apply
+{
+ printf 'action apply\n'
+ printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
+ printf 'strategy64 %s\n' "$(printf %s am3 | b64encode)"
+ printf 'patch64 %s\n' "$(b64encode < "$patch_file")"
+ printf 'end\n'
+} > "$work/env-apply"
+run_host "$work/env-apply" "$work/resp-apply"
+check "host: apply framing" $?
+grep -q '"ok":true' "$work/resp-apply"
+check "host: apply ok" $?
+[ "$(git -C "$upstream" log --format=%s -1)" = "add second line" ]
+check "host: commit landed" $?
+grep -q "two" "$upstream/file.txt"
+check "host: file content applied" $?
+
+# a conflicting patch must fail, and git am --abort must leave the repo clean
+echo "conflicting upstream change" > "$upstream/file.txt"
+git -C "$upstream" add file.txt
+git -C "$upstream" commit -q -m "upstream diverges"
+
+echo "contributor version" > "$contributor/file.txt"
+git -C "$contributor" add file.txt
+git -C "$contributor" commit -q -m "contributor change"
+git -C "$contributor" format-patch -q -1 -o "$work/patches2"
+conflict_patch=$(ls "$work/patches2"/0001-*)
+
+before=$(git -C "$upstream" rev-parse HEAD)
+{
+ printf 'action apply\n'
+ printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
+ printf 'patch64 %s\n' "$(b64encode < "$conflict_patch")"
+ printf 'end\n'
+} > "$work/env-conflict"
+run_host "$work/env-conflict" "$work/resp-conflict"
+check "host: conflict framing" $?
+grep -q '"ok":false' "$work/resp-conflict"
+check "host: conflict rejected" $?
+[ "$(git -C "$upstream" rev-parse HEAD)" = "$before" ] && \
+ [ -z "$(git -C "$upstream" status --porcelain)" ]
+check "host: repo clean after failed apply" $?
+
+# apply refuses a non-repo
+{
+ printf 'action apply\n'
+ printf 'repo64 %s\n' "$(printf %s "$work" | b64encode)"
+ printf 'patch64 %s\n' "$(b64encode < "$patch_file")"
+ printf 'end\n'
+} > "$work/env-norepo"
+run_host "$work/env-norepo" "$work/resp-norepo"
+grep -q '"ok":false' "$work/resp-norepo" && grep -q 'not a git repository' "$work/resp-norepo"
+check "host: rejects non-repo" $?
+
+# edit launches the configured command detached
+marker="$work/editor-ran"
+{
+ printf 'action edit\n'
+ printf 'repo64 %s\n' "$(printf %s "$upstream" | b64encode)"
+ printf 'editor64 %s\n' "$(printf %s "touch $marker" | b64encode)"
+ printf 'files64 %s\n' "$(printf %s "file.txt" | b64encode)"
+ printf 'end\n'
+} > "$work/env-edit"
+run_host "$work/env-edit" "$work/resp-edit"
+check "host: edit framing" $?
+grep -q '"ok":true' "$work/resp-edit"
+check "host: edit ok" $?
+tries=0
+while [ ! -f "$marker" ] && [ "$tries" -lt 50 ]; do
+ tries=$((tries + 1))
+ sleep 0.1 2>/dev/null || sleep 1
+done
+[ -f "$marker" ]
+check "host: editor command ran" $?
+
+# pickdir uses the first available chooser; fake zenity so no GUI is needed
+fake="$work/fakebin"
+mkdir -p "$fake"
+cat > "$fake/zenity" <<EOF
+#!/bin/sh
+printf '%s\n' "/picked/repo path"
+EOF
+chmod +x "$fake/zenity"
+
+printf 'action pickdir\nend\n' > "$work/env-pick"
+EXTRA_PATH=$fake
+run_host "$work/env-pick" "$work/resp-pick"
+check "host: pickdir framing" $?
+grep -q '"ok":true' "$work/resp-pick" && grep -q '/picked/repo path' "$work/resp-pick"
+check "host: pickdir returns chosen path" $?
+
+# cancelled chooser (zenity exits nonzero) reports ok + cancelled, no path
+cat > "$fake/zenity" <<EOF
+#!/bin/sh
+exit 1
+EOF
+run_host "$work/env-pick" "$work/resp-pick-cancel"
+grep -q '"cancelled":true' "$work/resp-pick-cancel"
+check "host: pickdir cancel" $?
+EXTRA_PATH=""
+
+# unknown action
+printf 'action bogus\nend\n' > "$work/env-bogus"
+run_host "$work/env-bogus" "$work/resp-bogus"
+grep -q '"ok":false' "$work/resp-bogus"
+check "host: unknown action rejected" $?
+
+# --- summary ----------------------------------------------------------------
+
+if [ "$failures" -gt 0 ]; then
+ echo "" >&2
+ echo "$failures failure(s)" >&2
+ exit 1
+fi
+echo ""
+echo "all host tests passed"
diff --git a/tests/run.mjs b/tests/run.mjs
@@ -0,0 +1,262 @@
+// Test runner for the pure extension modules (no Thunderbird required):
+// node tests/run.mjs
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ parseSubject,
+ bodyLooksLikeDiff,
+ isPatchMessage,
+ sameSeries,
+} from "../extension/modules/patch-detect.js";
+import { parsePatchEmail } from "../extension/modules/diff-parse.js";
+import { pairRanges, computeIntraline } from "../extension/modules/intraline.js";
+import { formatReview } from "../extension/modules/reply-format.js";
+import { locator, GENERAL } from "../extension/modules/review-store.js";
+import {
+ isSourcehutList,
+ SOURCEHUT_STATUSES,
+ STATUS_HEADER,
+} from "../extension/modules/sourcehut.js";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const fixture = (name) => readFileSync(join(here, "fixtures", name), "utf8");
+
+let failures = 0;
+function check(name, cond, extra = "") {
+ if (cond) {
+ console.log(`ok ${name}`);
+ } else {
+ failures++;
+ console.error(`FAIL ${name} ${extra}`);
+ }
+}
+
+// --- parseSubject -----------------------------------------------------------
+
+{
+ const s = parseSubject("[PATCH v2 3/7] net: fix socket leak");
+ check("subject: n/m and version", s && s.n === 3 && s.m === 7 && s.version === 2);
+ check("subject: title", s.title === "net: fix socket leak");
+ check("subject: prefix", s.prefix === "PATCH");
+
+ const rfc = parseSubject("Re: [RFC PATCH net-next 0/5] some series");
+ check("subject: reply + RFC + cover", rfc && rfc.n === 0 && rfc.prefix === "RFC PATCH NET-NEXT");
+
+ check("subject: bare [PATCH]", parseSubject("[PATCH] one-off fix").m === null);
+ check("subject: non-patch", parseSubject("Meeting notes") === null);
+ check("subject: PATCH word outside tag", parseSubject("please PATCH this") === null);
+}
+
+// --- sameSeries -------------------------------------------------------------
+
+{
+ const a = parseSubject("[PATCH v2 1/3] socket: validate file descriptors");
+ const b = parseSubject("[PATCH v2 3/3] assets: add icon");
+ const c = parseSubject("[PATCH v3 1/3] socket: validate file descriptors");
+ const d = parseSubject("[PATCH v2 1/4] other series");
+ check("sameSeries: same v and m", sameSeries(a, b));
+ check("sameSeries: different version", !sameSeries(a, c));
+ check("sameSeries: different size", !sameSeries(a, d));
+}
+
+// --- detection --------------------------------------------------------------
+
+{
+ check("diff detection: fixture", bodyLooksLikeDiff(fixture("patch1.body")));
+ check("diff detection: prose", !bodyLooksLikeDiff("just words\nabout --- things\n"));
+ check("isPatchMessage: tagged", isPatchMessage("[PATCH 1/2] x", fixture("patch1.body")));
+ check("isPatchMessage: untagged diff", isPatchMessage("a fix", fixture("patch1.body")));
+ check("isPatchMessage: cover letter", isPatchMessage("[PATCH 0/2] x", fixture("cover.body")));
+ check("isPatchMessage: plain mail", !isPatchMessage("hello", "how are you?\n"));
+}
+
+// --- diff parsing -----------------------------------------------------------
+
+{
+ const p = parsePatchEmail(fixture("patch1.body"));
+ check("parse: one file", p.files.length === 1, `got ${p.files.length}`);
+ check("parse: path", p.files[0].displayPath === "src/socket.c");
+ check("parse: commit message", p.commitMessage.startsWith("Reject negative descriptors"));
+ check("parse: diffstat", p.diffstat.includes("1 file changed"));
+
+ const hunk = p.files[0].hunks[0];
+ check("parse: single hunk", p.files[0].hunks.length === 1);
+ check("parse: hunk counts", hunk.oldCount === 15 && hunk.newCount === 20);
+
+ const firstAdd = hunk.lines.find((l) => l.origin === "add");
+ check("parse: first addition", firstAdd.text === "#include <errno.h>");
+ check("parse: new-line numbering", firstAdd.newLine === 2 && firstAdd.oldLine === null);
+
+ const firstDel = hunk.lines.find((l) => l.origin === "del");
+ check("parse: deletion numbering", firstDel.oldLine === 5 && firstDel.newLine === null);
+
+ // Raw lines must round-trip exactly for quoting in replies.
+ const start = hunk.lines[0].bodyLine;
+ const rawSlice = p.bodyLines.slice(start, start + hunk.lines.length).join("\n");
+ check("parse: raw round-trip", rawSlice === hunk.lines.map((l) => l.raw).join("\n"));
+}
+
+{
+ const p = parsePatchEmail(fixture("patch2.body"));
+ check("parse: new file flag", p.files[0].isNew);
+ check("parse: new file path", p.files[0].displayPath === "src/log.c");
+ check("parse: all lines added", p.files[0].hunks[0].lines.every((l) => l.origin === "add"));
+}
+
+{
+ const p = parsePatchEmail(fixture("patch3.body"));
+ check("parse: binary flag", p.files[0].isBinary, JSON.stringify(p.files[0]));
+ check("parse: binary has no hunks", p.files[0].hunks.length === 0);
+}
+
+{
+ const p = parsePatchEmail(fixture("cover.body"));
+ check("parse: cover has no files", p.files.length === 0);
+ check("parse: cover keeps text", p.commitMessage.length > 0);
+}
+
+{
+ // Single-line commit message: the body starts directly at the scissors.
+ // The commit message must be empty — never the diff (the review tab would
+ // otherwise render the whole raw patch above the interactive diff).
+ const body = [
+ "---",
+ " file.txt | 1 +",
+ " 1 file changed, 1 insertion(+)",
+ "",
+ "diff --git a/file.txt b/file.txt",
+ "index e69de29..8baef1b 100644",
+ "--- a/file.txt",
+ "+++ b/file.txt",
+ "@@ -0,0 +1 @@",
+ "+hello",
+ "-- ",
+ "2.45.0",
+ "",
+ ].join("\n");
+ const p = parsePatchEmail(body);
+ check("parse: scissors-first body has empty message", p.commitMessage === "");
+ check("parse: scissors-first body still parses diff", p.files.length === 1);
+ check("parse: scissors-first diffstat", p.diffstat.includes("1 file changed"));
+}
+
+// --- sourcehut detection ----------------------------------------------------
+
+{
+ check(
+ "sourcehut: recipient address form",
+ isSourcehutList(["Marc <marc@coquand.email>", "~marc/patches@lists.sr.ht"])
+ );
+ check(
+ "sourcehut: List-Id form",
+ isSourcehutList(["~marc/patches <~marc/patches.lists.sr.ht>"])
+ );
+ check("sourcehut: case insensitive", isSourcehutList(["dev@LISTS.SR.HT"]));
+ check(
+ "sourcehut: other lists rejected",
+ !isSourcehutList(["netdev.vger.kernel.org", "dev@lists.example.org"])
+ );
+ check("sourcehut: lookalike domain rejected", !isSourcehutList(["x@lists.sr.hteam.com"]));
+ check("sourcehut: empty/missing values", !isSourcehutList([]) && !isSourcehutList([null, ""]));
+ check(
+ "sourcehut: all statuses present",
+ ["PROPOSED", "NEEDS_REVISION", "SUPERSEDED", "APPROVED", "REJECTED", "APPLIED"].every((s) =>
+ SOURCEHUT_STATUSES.includes(s)
+ ) && SOURCEHUT_STATUSES.length === 6
+ );
+ check("sourcehut: header name", STATUS_HEADER === "X-Sourcehut-Patchset-Update");
+}
+
+// --- intra-line highlighting ------------------------------------------------
+
+{
+ const mid = pairRanges("return value;", "return newvalue;");
+ check("intraline: middle change", mid && mid.del[0] === 7 && mid.add[0] === 7);
+ check(
+ "intraline: add span text",
+ "return newvalue;".slice(mid.add[0], mid.add[1]) === "new"
+ );
+ check("intraline: del span empty for insertion", mid.del[0] === mid.del[1]);
+
+ const unrelated = pairRanges("int foo(void)", "#include <x.h>");
+ check("intraline: unrelated lines skipped", unrelated === null);
+
+ const lines = [
+ { origin: "ctx", text: "a", raw: " a" },
+ { origin: "del", text: "\tif (fd < 0)", raw: "-\tif (fd < 0)" },
+ { origin: "del", text: "old line", raw: "-old line" },
+ { origin: "add", text: "\tif (fd < 0) {", raw: "+\tif (fd < 0) {" },
+ { origin: "add", text: "new line", raw: "+new line" },
+ { origin: "ctx", text: "b", raw: " b" },
+ ];
+ const ranges = computeIntraline(lines);
+ check("intraline: context untouched", ranges[0] === null && ranges[5] === null);
+ check("intraline: pure suffix insertion skips del side", ranges[1] === null);
+ check(
+ "intraline: add side highlights suffix",
+ ranges[3] && "\tif (fd < 0) {".slice(ranges[3].start, ranges[3].end) === " {"
+ );
+ check(
+ "intraline: second pair highlights changed word",
+ ranges[2] &&
+ "old line".slice(ranges[2].start, ranges[2].end) === "old" &&
+ ranges[4] &&
+ "new line".slice(ranges[4].start, ranges[4].end) === "new"
+ );
+}
+
+{
+ // Real patch: "-\tif (fd < 0)" replaced by "+\tif (fd < 0) {" + more adds.
+ const p = parsePatchEmail(fixture("patch1.body"));
+ const hunk = p.files[0].hunks[0];
+ const ranges = computeIntraline(hunk.lines);
+ const del = hunk.lines.findIndex((l) => l.origin === "del");
+ check("intraline: fixture del pairs with first add", ranges[del] === null);
+ const add = hunk.lines.findIndex((l) => l.origin === "add" && l.text.endsWith("{"));
+ check(
+ "intraline: fixture add span",
+ ranges[add] && hunk.lines[add].text.slice(ranges[add].start, ranges[add].end) === " {"
+ );
+}
+
+// --- reply formatting -------------------------------------------------------
+
+{
+ const p = parsePatchEmail(fixture("patch1.body"));
+ const hunk = p.files[0].hunks[0];
+ const target = hunk.lines.findIndex((l) => l.text === "#include <errno.h>");
+ const later = hunk.lines.findIndex((l) => l.text.includes("errno = EBADF"));
+
+ const comments = {
+ [GENERAL]: { text: "Looks good overall, two nits." },
+ [locator(0, 0, target)]: { text: "Is errno.h needed on all platforms?" },
+ [locator(0, 0, later)]: { text: "EBADF or EINVAL here?" },
+ };
+ const reply = formatReview(p, comments);
+
+ check("reply: general comment first", reply.startsWith("Looks good overall"));
+ check("reply: quotes file header", reply.includes("> diff --git a/src/socket.c b/src/socket.c"));
+ check("reply: quotes hunk header", reply.includes(`> ${hunk.header}`));
+ check("reply: quotes target line", reply.includes("> +#include <errno.h>"));
+ const quoted = reply.indexOf("> +#include <errno.h>");
+ const comment = reply.indexOf("Is errno.h needed");
+ check("reply: comment follows its line", quoted !== -1 && comment > quoted);
+ const second = reply.indexOf("EBADF or EINVAL");
+ check("reply: comment order", second > comment);
+ check(
+ "reply: unquoted comments",
+ !reply.includes("> Is errno.h") && !reply.includes("> EBADF or EINVAL")
+ );
+
+ check("reply: empty without comments", formatReview(p, {}) === "");
+}
+
+// --- summary ----------------------------------------------------------------
+
+if (failures) {
+ console.error(`\n${failures} failure(s)`);
+ process.exit(1);
+}
+console.log("\nall tests passed");