diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..873a6c1
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,52 @@
+# Build stage
+FROM golang:1.24-alpine AS builder
+
+# Install build dependencies
+RUN apk add --no-cache gcc musl-dev sqlite-dev
+
+WORKDIR /app
+
+# Copy go mod files
+COPY go.mod go.sum ./
+RUN go mod download
+
+# Copy source code
+COPY . .
+
+# Build with CGO for SQLite
+ENV CGO_ENABLED=1
+RUN go build -ldflags="-s -w" -o /gitea-notification-hub ./cmd/server
+
+# Runtime stage
+FROM alpine:3.19
+
+# Install runtime dependencies
+RUN apk add --no-cache ca-certificates sqlite-libs tzdata
+
+# Create non-root user
+RUN adduser -D -g '' appuser
+
+WORKDIR /app
+
+# Copy binary from builder
+COPY --from=builder /gitea-notification-hub .
+
+# Copy entrypoint script
+COPY docker-entrypoint.sh .
+RUN chmod +x docker-entrypoint.sh
+
+# Create data directory
+RUN mkdir -p /app/data && chown -R appuser:appuser /app
+
+# Switch to non-root user
+USER appuser
+
+# Expose port
+EXPOSE 8080
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
+
+# Run
+ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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
+.
diff --git a/cmd/server/main.go b/cmd/server/main.go
new file mode 100644
index 0000000..cd2be12
--- /dev/null
+++ b/cmd/server/main.go
@@ -0,0 +1,138 @@
+package main
+
+import (
+ "context"
+ "flag"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "github.com/rs/zerolog"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/config"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/event"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/identity"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/identity/cache"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/identity/gitea"
+ slacknotifier "github.com/vincentc-afk/gitea-notification-hub/internal/notifier/slack"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/server"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/storage/sqlite"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/webhook"
+)
+
+func main() {
+ // Parse flags
+ configPath := flag.String("config", "config/config.yaml", "path to config file")
+ debug := flag.Bool("debug", false, "enable debug logging")
+ flag.Parse()
+
+ // Setup logger
+ logger := setupLogger(*debug)
+
+ // Load configuration
+ cfg, err := config.Load(*configPath)
+ if err != nil {
+ logger.Fatal().Err(err).Msg("failed to load configuration")
+ }
+ logger.Info().Str("path", *configPath).Msg("configuration loaded")
+
+ ctx := context.Background()
+
+ // Initialize storage
+ repo, err := sqlite.New(cfg.Database.DSN)
+ if err != nil {
+ logger.Fatal().Err(err).Msg("failed to initialize database")
+ }
+ defer repo.Close()
+
+ // Run migrations
+ if err := repo.Migrate(ctx); err != nil {
+ logger.Fatal().Err(err).Msg("failed to run migrations")
+ }
+ logger.Info().Msg("database initialized")
+
+ // Initialize Gitea API provider for email lookup
+ var emailLookup identity.EmailLookup
+ if cfg.Identity.Gitea.URL != "" {
+ giteaProvider := gitea.New(&cfg.Identity.Gitea, logger)
+ emailLookup = giteaProvider
+ logger.Info().Str("url", cfg.Identity.Gitea.URL).Msg("Gitea identity provider initialized")
+ } else {
+ logger.Warn().Msg("Gitea URL not configured, email lookup disabled")
+ }
+
+ // Initialize Slack notifier
+ slackClient := slacknotifier.New(&cfg.Notification.Slack, logger)
+ logger.Info().Msg("Slack notifier initialized")
+
+ // Initialize cached identity resolver
+ resolver := cache.NewCachedResolver(repo, emailLookup, slackClient, logger)
+ logger.Info().Msg("identity resolver initialized")
+
+ // Create processor adapter that implements webhook.EventHandler
+ processor := event.NewProcessor(cfg, &resolverAdapter{resolver}, slackClient, logger)
+ logger.Info().Msg("event processor initialized")
+
+ // Create HTTP server
+ srv := server.New(cfg, logger)
+
+ // Register webhook handler
+ webhookHandler := webhook.NewHandler(cfg.Server.WebhookSecret, processor, logger)
+ srv.Router().Post("/webhook", webhookHandler.ServeHTTP)
+ logger.Info().Msg("webhook handler registered at POST /webhook")
+
+ // Graceful shutdown
+ go func() {
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
+ <-sigCh
+
+ logger.Info().Msg("received shutdown signal")
+
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ logger.Error().Err(err).Msg("shutdown error")
+ }
+ }()
+
+ // Start server
+ logger.Info().Int("port", cfg.Server.Port).Msg("starting server")
+ if err := srv.Start(); err != nil && err != http.ErrServerClosed {
+ logger.Fatal().Err(err).Msg("server error")
+ }
+}
+
+func setupLogger(debug bool) zerolog.Logger {
+ zerolog.TimeFieldFormat = time.RFC3339
+
+ level := zerolog.InfoLevel
+ if debug {
+ level = zerolog.DebugLevel
+ }
+
+ return zerolog.New(zerolog.ConsoleWriter{
+ Out: os.Stdout,
+ TimeFormat: "15:04:05",
+ }).Level(level).With().Timestamp().Caller().Logger()
+}
+
+// resolverAdapter adapts cache.CachedResolver to event.IdentityResolver
+type resolverAdapter struct {
+ resolver *cache.CachedResolver
+}
+
+func (a *resolverAdapter) Resolve(ctx context.Context, user event.User) (*event.ResolvedIdentity, error) {
+ identity, err := a.resolver.Resolve(ctx, user)
+ if err != nil {
+ return nil, err
+ }
+ return &event.ResolvedIdentity{
+ Email: identity.Email,
+ SlackID: identity.SlackID,
+ SlackName: identity.SlackName,
+ }, nil
+}
diff --git a/config/config.example.yaml b/config/config.example.yaml
new file mode 100644
index 0000000..757ffd4
--- /dev/null
+++ b/config/config.example.yaml
@@ -0,0 +1,42 @@
+# Gitea Notification Hub Configuration
+
+server:
+ port: 8080
+ # Gitea webhook secret - use environment variable in production
+ webhook_secret: "${GITEA_WEBHOOK_SECRET}"
+
+identity:
+ provider: gitea
+ cache_ttl: 24h
+ gitea:
+ # Gitea server URL (without trailing slash)
+ url: "${GITEA_URL}"
+ # Gitea API token for user lookup (needs read:user scope)
+ token: "${GITEA_API_TOKEN}"
+
+notification:
+ provider: slack
+ slack:
+ # Slack Bot OAuth Token (xoxb-...) - use environment variable
+ bot_token: "${SLACK_BOT_TOKEN}"
+ # Default channel for notifications (fallback)
+ default_channel: "#gitea-notifications"
+
+database:
+ # Database driver: sqlite or postgres
+ driver: sqlite
+ # SQLite: file path, PostgreSQL: connection string
+ dsn: "./data/notifications.db"
+
+# Notification rules - who gets notified for each event type
+rules:
+ pr:
+ notify_owner: true # Notify PR author on reviews/comments
+ notify_reviewers: true # Notify requested reviewers
+ notify_assignees: true # Notify PR assignees
+ issue:
+ notify_assignees: true # Notify issue assignees
+ comment:
+ notify_mentioned: true # Notify @mentioned users
+ notify_thread_owner: true # Notify PR/Issue author
+ notify_reviewers: true # Notify PR reviewers on comments
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..e08c503
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+version: '3.8'
+
+services:
+ gitea-notification-hub:
+ build: .
+ container_name: gitea-notification-hub
+ restart: unless-stopped
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./config:/app/config:ro
+ - ./data:/app/data
+ environment:
+ - GITEA_WEBHOOK_SECRET=${GITEA_WEBHOOK_SECRET}
+ - GITEA_URL=${GITEA_URL}
+ - GITEA_API_TOKEN=${GITEA_API_TOKEN}
+ - SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN}
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"]
+ interval: 30s
+ timeout: 3s
+ retries: 3
+ start_period: 5s
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100755
index 0000000..993fe41
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+set -e
+
+# Generate config.yaml from environment variables
+mkdir -p /app/config
+
+cat > /app/config/config.yaml << 'YAMLEOF'
+server:
+ port: 8080
+ webhook_secret: "${GITEA_WEBHOOK_SECRET}"
+
+identity:
+ provider: gitea
+ cache_ttl: 24h
+ gitea:
+ url: "${GITEA_URL}"
+ token: "${GITEA_API_TOKEN}"
+
+notification:
+ provider: slack
+ slack:
+ bot_token: "${SLACK_BOT_TOKEN}"
+
+database:
+ driver: sqlite
+ dsn: "./data/notifications.db"
+
+rules:
+ pr:
+ notify_owner: true
+ notify_reviewers: true
+ notify_assignees: true
+ issue:
+ notify_assignees: true
+ comment:
+ notify_mentioned: true
+ notify_thread_owner: true
+ notify_reviewers: true
+YAMLEOF
+
+# Expand env vars in config
+# The Go app uses os.ExpandEnv so it handles ${VAR} syntax itself
+# We just need to ensure the file is present
+
+exec ./gitea-notification-hub -config /app/config/config.yaml
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..e789738
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,24 @@
+module github.com/vincentc-afk/gitea-notification-hub
+
+go 1.24.0
+
+toolchain go1.24.2
+
+require (
+ github.com/go-chi/chi/v5 v5.2.4
+ github.com/mattn/go-sqlite3 v1.14.33
+ github.com/rs/zerolog v1.34.0
+ github.com/slack-go/slack v0.17.3
+ gopkg.in/yaml.v3 v3.0.1
+)
+
+require (
+ github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/kr/pretty v0.3.1 // indirect
+ github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-isatty v0.0.19 // indirect
+ github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ golang.org/x/sys v0.39.0 // indirect
+ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..fedb0a8
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,49 @@
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4=
+github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
+github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
+github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
+github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
+github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
+github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
+github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
+github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
+github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
+golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..d6716d8
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,100 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "time"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Config holds all configuration for the application
+type Config struct {
+ Server ServerConfig `yaml:"server"`
+ Identity IdentityConfig `yaml:"identity"`
+ Notification NotificationConfig `yaml:"notification"`
+ Database DatabaseConfig `yaml:"database"`
+ Rules RulesConfig `yaml:"rules"`
+}
+
+type ServerConfig struct {
+ Port int `yaml:"port"`
+ WebhookSecret string `yaml:"webhook_secret"`
+}
+
+type IdentityConfig struct {
+ Provider string `yaml:"provider"`
+ CacheTTL time.Duration `yaml:"cache_ttl"`
+ Gitea GiteaConfig `yaml:"gitea"`
+}
+
+type GiteaConfig struct {
+ URL string `yaml:"url"`
+ Token string `yaml:"token"`
+}
+
+type NotificationConfig struct {
+ Provider string `yaml:"provider"`
+ Slack SlackConfig `yaml:"slack"`
+}
+
+type SlackConfig struct {
+ BotToken string `yaml:"bot_token"`
+ DefaultChannel string `yaml:"default_channel"`
+}
+
+type DatabaseConfig struct {
+ Driver string `yaml:"driver"`
+ DSN string `yaml:"dsn"`
+}
+
+type RulesConfig struct {
+ PR PRRules `yaml:"pr"`
+ Issue IssueRules `yaml:"issue"`
+ Comment CommentRules `yaml:"comment"`
+}
+
+type PRRules struct {
+ NotifyOwner bool `yaml:"notify_owner"`
+ NotifyReviewers bool `yaml:"notify_reviewers"`
+ NotifyAssignees bool `yaml:"notify_assignees"`
+}
+
+type IssueRules struct {
+ NotifyAssignees bool `yaml:"notify_assignees"`
+}
+
+type CommentRules struct {
+ NotifyMentioned bool `yaml:"notify_mentioned"`
+ NotifyThreadOwner bool `yaml:"notify_thread_owner"`
+ NotifyReviewers bool `yaml:"notify_reviewers"`
+}
+
+// Load reads configuration from a YAML file
+func Load(path string) (*Config, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("reading config file: %w", err)
+ }
+
+ // Expand environment variables
+ data = []byte(os.ExpandEnv(string(data)))
+
+ var cfg Config
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ return nil, fmt.Errorf("parsing config file: %w", err)
+ }
+
+ // Set defaults
+ if cfg.Server.Port == 0 {
+ cfg.Server.Port = 8080
+ }
+ if cfg.Identity.CacheTTL == 0 {
+ cfg.Identity.CacheTTL = 24 * time.Hour
+ }
+ if cfg.Database.Driver == "" {
+ cfg.Database.Driver = "sqlite"
+ }
+
+ return &cfg, nil
+}
diff --git a/internal/event/mention.go b/internal/event/mention.go
new file mode 100644
index 0000000..9ced2e2
--- /dev/null
+++ b/internal/event/mention.go
@@ -0,0 +1,86 @@
+package event
+
+import (
+ "regexp"
+ "strings"
+)
+
+// mentionRegex matches @username patterns
+// Supports alphanumeric characters, underscores, and hyphens
+var mentionRegex = regexp.MustCompile(`@([a-zA-Z0-9_-]+)`)
+
+// ExtractMentions extracts all @mentioned usernames from text
+func ExtractMentions(text string) []string {
+ if text == "" {
+ return nil
+ }
+
+ matches := mentionRegex.FindAllStringSubmatch(text, -1)
+ if len(matches) == 0 {
+ return nil
+ }
+
+ // Deduplicate mentions
+ seen := make(map[string]bool)
+ var usernames []string
+
+ for _, match := range matches {
+ if len(match) < 2 {
+ continue
+ }
+ username := strings.ToLower(match[1])
+
+ // Skip common false positives
+ if isCommonFalsePositive(username) {
+ continue
+ }
+
+ if !seen[username] {
+ seen[username] = true
+ usernames = append(usernames, username)
+ }
+ }
+
+ return usernames
+}
+
+// ReplaceMentionsWithSlackIDs replaces @username with <@SLACK_ID> format
+func ReplaceMentionsWithSlackIDs(text string, usernameToSlackID map[string]string) string {
+ if text == "" || len(usernameToSlackID) == 0 {
+ return text
+ }
+
+ result := mentionRegex.ReplaceAllStringFunc(text, func(match string) string {
+ username := strings.ToLower(strings.TrimPrefix(match, "@"))
+ if slackID, ok := usernameToSlackID[username]; ok {
+ return "<@" + slackID + ">"
+ }
+ return match // Keep original if no mapping found
+ })
+
+ return result
+}
+
+// isCommonFalsePositive checks if a mention is likely not a real username
+func isCommonFalsePositive(username string) bool {
+ // Common patterns that look like mentions but aren't
+ falsePositives := map[string]bool{
+ "param": true,
+ "returns": true,
+ "throws": true,
+ "deprecated": true,
+ "see": true,
+ "link": true,
+ "code": true,
+ "example": true,
+ "todo": true,
+ "fixme": true,
+ "note": true,
+ "warning": true,
+ "author": true,
+ "version": true,
+ "since": true,
+ }
+
+ return falsePositives[username]
+}
diff --git a/internal/event/processor.go b/internal/event/processor.go
new file mode 100644
index 0000000..889f7fe
--- /dev/null
+++ b/internal/event/processor.go
@@ -0,0 +1,564 @@
+package event
+
+import (
+ "context"
+ "strings"
+ "time"
+
+ "github.com/rs/zerolog"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/config"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/webhook"
+)
+
+// IdentityResolver resolves Gitea users to external identities (e.g., Slack)
+type IdentityResolver interface {
+ Resolve(ctx context.Context, user User) (*ResolvedIdentity, error)
+}
+
+// ResolvedIdentity represents a resolved external identity
+type ResolvedIdentity struct {
+ Email string `json:"email"`
+ SlackID string `json:"slack_id"`
+ SlackName string `json:"slack_name"`
+}
+
+// Notifier sends notifications
+type Notifier interface {
+ SendDirect(ctx context.Context, userID string, msg *Notification) error
+ SendChannel(ctx context.Context, channel string, msg *Notification) error
+}
+
+// Processor processes webhook events and generates notifications
+type Processor struct {
+ cfg *config.Config
+ resolver IdentityResolver
+ notifier Notifier
+ logger zerolog.Logger
+}
+
+// NewProcessor creates a new event processor
+func NewProcessor(cfg *config.Config, resolver IdentityResolver, notifier Notifier, logger zerolog.Logger) *Processor {
+ return &Processor{
+ cfg: cfg,
+ resolver: resolver,
+ notifier: notifier,
+ logger: logger.With().Str("component", "processor").Logger(),
+ }
+}
+
+// HandlePullRequest processes pull request events
+func (p *Processor) HandlePullRequest(e *webhook.PullRequestEvent) {
+ ctx := context.Background()
+ event := p.normalizePREvent(e)
+
+ p.logger.Debug().
+ Str("action", e.Action).
+ Int("assignees_count", len(event.Assignees)).
+ Int("reviewers_count", len(event.Reviewers)).
+ Str("actor", event.Actor.GiteaUsername).
+ Msg("processing PR event")
+
+ var usersToNotify []struct {
+ user User
+ reason NotificationReason
+ }
+
+ switch e.Action {
+ case "opened":
+ event.Type = TypePROpened
+ // Notify assignees and reviewers
+ if p.cfg.Rules.PR.NotifyAssignees {
+ for _, u := range event.Assignees {
+ p.logger.Debug().Str("assignee", u.GiteaUsername).Msg("adding assignee to notify")
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{u, ReasonAssignee})
+ }
+ }
+ if p.cfg.Rules.PR.NotifyReviewers {
+ for _, u := range event.Reviewers {
+ p.logger.Debug().Str("reviewer", u.GiteaUsername).Msg("adding reviewer to notify")
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{u, ReasonReviewer})
+ }
+ }
+
+ case "closed":
+ if e.PullRequest.Merged {
+ event.Type = TypePRMerged
+ } else {
+ event.Type = TypePRClosed
+ }
+ // Notify owner
+ if p.cfg.Rules.PR.NotifyOwner && event.Owner != nil {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{*event.Owner, ReasonOwner})
+ }
+
+ case "assigned":
+ event.Type = TypePRAssigned
+ // Notify newly assigned user
+ if e.Assignee != nil && p.cfg.Rules.PR.NotifyAssignees {
+ p.logger.Debug().Str("assignee", e.Assignee.Login).Msg("adding newly assigned user to notify")
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{giteaUserToUser(*e.Assignee), ReasonAssignee})
+ }
+
+ case "review_requested":
+ event.Type = TypePRReviewRequested
+ // Notify newly requested reviewer (singular - Gitea sends one at a time)
+ if e.RequestedReviewer != nil {
+ p.logger.Debug().Str("requested_reviewer", e.RequestedReviewer.Login).Msg("review_requested event")
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{giteaUserToUser(*e.RequestedReviewer), ReasonReviewer})
+ } else {
+ p.logger.Debug().Msg("review_requested event but no RequestedReviewer field")
+ }
+
+ case "synchronize":
+ event.Type = TypePRSynchronized
+ // Add commits info to event
+ event.Commits = make([]CommitInfo, 0, len(e.Commits))
+ for _, c := range e.Commits {
+ // Get first line of commit message
+ msg := c.Message
+ if idx := strings.Index(msg, "\n"); idx > 0 {
+ msg = msg[:idx]
+ }
+ event.Commits = append(event.Commits, CommitInfo{
+ SHA: c.ID[:7], // Short SHA
+ Message: msg,
+ URL: c.URL,
+ Author: c.Author.Name,
+ })
+ }
+ // Notify owner (will be filtered out if they pushed the commits)
+ if p.cfg.Rules.PR.NotifyOwner && event.Owner != nil {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{*event.Owner, ReasonOwner})
+ }
+ // Notify reviewers
+ if p.cfg.Rules.PR.NotifyReviewers {
+ for _, u := range event.Reviewers {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{u, ReasonReviewer})
+ }
+ }
+ // Notify assignees
+ if p.cfg.Rules.PR.NotifyAssignees {
+ for _, u := range event.Assignees {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{u, ReasonAssignee})
+ }
+ }
+ }
+
+ // Remove the actor from notifications (don't notify yourself)
+ beforeFilter := len(usersToNotify)
+ usersToNotify = p.filterOutActor(usersToNotify, event.Actor)
+ p.logger.Debug().
+ Int("before_filter", beforeFilter).
+ Int("after_filter", len(usersToNotify)).
+ Str("actor_filtered", event.Actor.GiteaUsername).
+ Msg("filtered out actor from notifications")
+
+ // Send notifications
+ p.sendNotifications(ctx, event, usersToNotify)
+}
+
+// HandlePullRequestReview processes PR review events
+func (p *Processor) HandlePullRequestReview(e *webhook.PullRequestReviewEvent) {
+ ctx := context.Background()
+ event := p.normalizeReviewEvent(e)
+
+ var usersToNotify []struct {
+ user User
+ reason NotificationReason
+ }
+
+ // Notify PR owner about the review
+ if p.cfg.Rules.PR.NotifyOwner && event.Owner != nil {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{*event.Owner, ReasonOwner})
+ }
+
+ // Remove the actor
+ usersToNotify = p.filterOutActor(usersToNotify, event.Actor)
+
+ p.sendNotifications(ctx, event, usersToNotify)
+}
+
+// HandlePullRequestComment processes PR comment events
+func (p *Processor) HandlePullRequestComment(e *webhook.PullRequestCommentEvent) {
+ ctx := context.Background()
+ event := p.normalizePRCommentEvent(e)
+
+ if e.Action != "created" {
+ return // Only notify on new comments
+ }
+
+ var usersToNotify []struct {
+ user User
+ reason NotificationReason
+ }
+
+ // Notify PR owner
+ if p.cfg.Rules.Comment.NotifyThreadOwner && event.Owner != nil {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{*event.Owner, ReasonOwner})
+ }
+
+ // Extract and notify mentioned users
+ if p.cfg.Rules.Comment.NotifyMentioned {
+ mentionedUsers := ExtractMentions(e.Comment.Body)
+ for _, username := range mentionedUsers {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{User{GiteaUsername: username}, ReasonMention})
+ }
+ }
+
+ // Notify reviewers
+ if p.cfg.Rules.Comment.NotifyReviewers {
+ for _, u := range event.Reviewers {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{u, ReasonReviewer})
+ }
+ }
+
+ // Remove the actor
+ usersToNotify = p.filterOutActor(usersToNotify, event.Actor)
+
+ p.sendNotifications(ctx, event, usersToNotify)
+}
+
+// HandleIssue processes issue events
+func (p *Processor) HandleIssue(e *webhook.IssueEvent) {
+ ctx := context.Background()
+ event := p.normalizeIssueEvent(e)
+
+ var usersToNotify []struct {
+ user User
+ reason NotificationReason
+ }
+
+ switch e.Action {
+ case "opened":
+ event.Type = TypeIssueOpened
+ // Notify assignees
+ if p.cfg.Rules.Issue.NotifyAssignees {
+ for _, u := range event.Assignees {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{u, ReasonAssignee})
+ }
+ }
+
+ case "closed":
+ event.Type = TypeIssueClosed
+ // Notify owner
+ if event.Owner != nil {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{*event.Owner, ReasonOwner})
+ }
+ }
+
+ // Remove the actor
+ usersToNotify = p.filterOutActor(usersToNotify, event.Actor)
+
+ p.sendNotifications(ctx, event, usersToNotify)
+}
+
+// HandleIssueComment processes issue comment events
+func (p *Processor) HandleIssueComment(e *webhook.IssueCommentEvent) {
+ ctx := context.Background()
+ event := p.normalizeIssueCommentEvent(e)
+
+ if e.Action != "created" {
+ return
+ }
+
+ var usersToNotify []struct {
+ user User
+ reason NotificationReason
+ }
+
+ // Notify issue owner
+ if p.cfg.Rules.Comment.NotifyThreadOwner && event.Owner != nil {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{*event.Owner, ReasonOwner})
+ }
+
+ // Extract and notify mentioned users
+ if p.cfg.Rules.Comment.NotifyMentioned {
+ mentionedUsers := ExtractMentions(e.Comment.Body)
+ for _, username := range mentionedUsers {
+ usersToNotify = append(usersToNotify, struct {
+ user User
+ reason NotificationReason
+ }{User{GiteaUsername: username}, ReasonMention})
+ }
+ }
+
+ // Remove the actor
+ usersToNotify = p.filterOutActor(usersToNotify, event.Actor)
+
+ p.sendNotifications(ctx, event, usersToNotify)
+}
+
+// sendNotifications resolves users and sends notifications
+func (p *Processor) sendNotifications(ctx context.Context, event *Event, users []struct {
+ user User
+ reason NotificationReason
+}) {
+ // Deduplicate users
+ seen := make(map[string]bool)
+
+ for _, u := range users {
+ key := u.user.GiteaUsername
+ if key == "" {
+ key = u.user.Email
+ }
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+
+ // Resolve user identity
+ identity, err := p.resolver.Resolve(ctx, u.user)
+ if err != nil {
+ p.logger.Warn().
+ Err(err).
+ Str("username", u.user.GiteaUsername).
+ Msg("failed to resolve user identity")
+ continue
+ }
+
+ if identity.SlackID == "" {
+ p.logger.Debug().
+ Str("username", u.user.GiteaUsername).
+ Msg("user has no Slack ID, skipping notification")
+ continue
+ }
+
+ // Create notification
+ notification := &Notification{
+ TargetUser: u.user,
+ Event: event,
+ Reason: u.reason,
+ Message: p.formatMessage(event, u.reason),
+ }
+
+ // Send DM
+ if err := p.notifier.SendDirect(ctx, identity.SlackID, notification); err != nil {
+ p.logger.Error().
+ Err(err).
+ Str("slack_id", identity.SlackID).
+ Msg("failed to send notification")
+ } else {
+ p.logger.Info().
+ Str("slack_id", identity.SlackID).
+ Str("reason", string(u.reason)).
+ Str("event_type", string(event.Type)).
+ Msg("notification sent")
+ }
+ }
+}
+
+// filterOutActor removes the event actor from the notification list
+func (p *Processor) filterOutActor(users []struct {
+ user User
+ reason NotificationReason
+}, actor User) []struct {
+ user User
+ reason NotificationReason
+} {
+ var filtered []struct {
+ user User
+ reason NotificationReason
+ }
+ for _, u := range users {
+ if u.user.GiteaUsername != actor.GiteaUsername {
+ filtered = append(filtered, u)
+ }
+ }
+ return filtered
+}
+
+// formatMessage creates a human-readable notification message
+func (p *Processor) formatMessage(event *Event, reason NotificationReason) string {
+ // This will be enhanced later with templates
+ switch event.Type {
+ case TypePROpened:
+ return "New PR opened"
+ case TypePRClosed:
+ return "PR closed"
+ case TypePRMerged:
+ return "PR merged"
+ case TypePRReviewRequested:
+ return "Review requested"
+ case TypePRReviewed:
+ return "PR reviewed"
+ case TypePRCommented:
+ return "New comment on PR"
+ case TypeIssueOpened:
+ return "New issue opened"
+ case TypeIssueClosed:
+ return "Issue closed"
+ case TypeIssueCommented:
+ return "New comment on issue"
+ default:
+ return "New notification"
+ }
+}
+
+// Normalization helpers
+func (p *Processor) normalizePREvent(e *webhook.PullRequestEvent) *Event {
+ owner := giteaUserToUser(e.PullRequest.User)
+
+ // Get reviewers from both possible locations:
+ // - e.PullRequest.RequestedReviewers: present in opened action
+ // - e.RequestedReviewers: present in review_requested action
+ var reviewers []User
+ if len(e.PullRequest.RequestedReviewers) > 0 {
+ reviewers = giteaUsersToUsers(e.PullRequest.RequestedReviewers)
+ } else if len(e.RequestedReviewers) > 0 {
+ reviewers = giteaUsersToUsers(e.RequestedReviewers)
+ }
+
+ return &Event{
+ Timestamp: time.Now(),
+ Actor: giteaUserToUser(e.Sender),
+ RepoName: e.Repository.Name,
+ RepoFullName: e.Repository.FullName,
+ RepoURL: e.Repository.HTMLURL,
+ Number: e.PullRequest.Number,
+ Title: e.PullRequest.Title,
+ Body: e.PullRequest.Body,
+ URL: e.PullRequest.HTMLURL,
+ Owner: &owner,
+ Assignees: giteaUsersToUsers(e.PullRequest.Assignees),
+ Reviewers: reviewers,
+ }
+}
+
+func (p *Processor) normalizeReviewEvent(e *webhook.PullRequestReviewEvent) *Event {
+ owner := giteaUserToUser(e.PullRequest.User)
+ return &Event{
+ Type: TypePRReviewed,
+ Timestamp: time.Now(),
+ Actor: giteaUserToUser(e.Sender),
+ RepoName: e.Repository.Name,
+ RepoFullName: e.Repository.FullName,
+ RepoURL: e.Repository.HTMLURL,
+ Number: e.PullRequest.Number,
+ Title: e.PullRequest.Title,
+ URL: e.PullRequest.HTMLURL,
+ Owner: &owner,
+ ReviewState: e.Review.State,
+ CommentBody: e.Review.Body,
+ }
+}
+
+func (p *Processor) normalizePRCommentEvent(e *webhook.PullRequestCommentEvent) *Event {
+ owner := giteaUserToUser(e.PullRequest.User)
+ return &Event{
+ Type: TypePRCommented,
+ Timestamp: time.Now(),
+ Actor: giteaUserToUser(e.Sender),
+ RepoName: e.Repository.Name,
+ RepoFullName: e.Repository.FullName,
+ RepoURL: e.Repository.HTMLURL,
+ Number: e.PullRequest.Number,
+ Title: e.PullRequest.Title,
+ URL: e.PullRequest.HTMLURL,
+ Owner: &owner,
+ Reviewers: giteaUsersToUsers(e.PullRequest.RequestedReviewers),
+ CommentBody: e.Comment.Body,
+ CommentURL: e.Comment.HTMLURL,
+ }
+}
+
+func (p *Processor) normalizeIssueEvent(e *webhook.IssueEvent) *Event {
+ owner := giteaUserToUser(e.Issue.User)
+ return &Event{
+ Timestamp: time.Now(),
+ Actor: giteaUserToUser(e.Sender),
+ RepoName: e.Repository.Name,
+ RepoFullName: e.Repository.FullName,
+ RepoURL: e.Repository.HTMLURL,
+ Number: e.Issue.Number,
+ Title: e.Issue.Title,
+ Body: e.Issue.Body,
+ URL: e.Issue.HTMLURL,
+ Owner: &owner,
+ Assignees: giteaUsersToUsers(e.Issue.Assignees),
+ }
+}
+
+func (p *Processor) normalizeIssueCommentEvent(e *webhook.IssueCommentEvent) *Event {
+ owner := giteaUserToUser(e.Issue.User)
+ return &Event{
+ Type: TypeIssueCommented,
+ Timestamp: time.Now(),
+ Actor: giteaUserToUser(e.Sender),
+ RepoName: e.Repository.Name,
+ RepoFullName: e.Repository.FullName,
+ RepoURL: e.Repository.HTMLURL,
+ Number: e.Issue.Number,
+ Title: e.Issue.Title,
+ URL: e.Issue.HTMLURL,
+ Owner: &owner,
+ CommentBody: e.Comment.Body,
+ CommentURL: e.Comment.HTMLURL,
+ }
+}
+
+func giteaUserToUser(u webhook.GiteaUser) User {
+ username := u.Login
+ if username == "" {
+ username = u.Username
+ }
+ return User{
+ GiteaID: u.ID,
+ GiteaUsername: username,
+ Email: u.Email,
+ FullName: u.FullName,
+ }
+}
+
+func giteaUsersToUsers(users []webhook.GiteaUser) []User {
+ result := make([]User, len(users))
+ for i, u := range users {
+ result[i] = giteaUserToUser(u)
+ }
+ return result
+}
diff --git a/internal/event/types.go b/internal/event/types.go
new file mode 100644
index 0000000..2600f0e
--- /dev/null
+++ b/internal/event/types.go
@@ -0,0 +1,100 @@
+package event
+
+import "time"
+
+// Type represents the normalized event type
+type Type string
+
+const (
+ TypePROpened Type = "pr_opened"
+ TypePRClosed Type = "pr_closed"
+ TypePRMerged Type = "pr_merged"
+ TypePRAssigned Type = "pr_assigned"
+ TypePRReviewRequested Type = "pr_review_requested"
+ TypePRReviewed Type = "pr_reviewed"
+ TypePRCommented Type = "pr_commented"
+ TypePRSynchronized Type = "pr_synchronized" // New commits pushed
+ TypeIssueOpened Type = "issue_opened"
+ TypeIssueClosed Type = "issue_closed"
+ TypeIssueCommented Type = "issue_commented"
+)
+
+// User represents a user in our normalized event model
+type User struct {
+ GiteaID int64 `json:"gitea_id"`
+ GiteaUsername string `json:"gitea_username"`
+ Email string `json:"email"`
+ FullName string `json:"full_name"`
+}
+
+// Event is a normalized event from Gitea
+type Event struct {
+ ID string `json:"id"` // Delivery ID from Gitea
+ Type Type `json:"type"` // Normalized event type
+ Timestamp time.Time `json:"timestamp"` // When the event occurred
+
+ // The user who triggered the event
+ Actor User `json:"actor"`
+
+ // Repository info
+ RepoName string `json:"repo_name"`
+ RepoFullName string `json:"repo_full_name"`
+ RepoURL string `json:"repo_url"`
+
+ // PR/Issue info
+ Number int64 `json:"number"` // PR or Issue number
+ Title string `json:"title"`
+ Body string `json:"body"`
+ URL string `json:"url"`
+
+ // Users to potentially notify (based on roles)
+ Owner *User `json:"owner,omitempty"` // PR/Issue author
+ Assignees []User `json:"assignees,omitempty"` // Assigned users
+ Reviewers []User `json:"reviewers,omitempty"` // Requested reviewers (PR only)
+
+ // For review events
+ ReviewState string `json:"review_state,omitempty"` // APPROVED, CHANGES_REQUESTED, COMMENT
+
+ // For comment events
+ CommentBody string `json:"comment_body,omitempty"`
+ CommentURL string `json:"comment_url,omitempty"`
+
+ // For synchronize events (new commits pushed)
+ Commits []CommitInfo `json:"commits,omitempty"`
+}
+
+// CommitInfo represents a commit in a synchronized event
+type CommitInfo struct {
+ SHA string `json:"sha"`
+ Message string `json:"message"`
+ URL string `json:"url"`
+ Author string `json:"author"`
+}
+
+// Notification represents a notification to be sent
+type Notification struct {
+ // Target user to notify
+ TargetUser User `json:"target_user"`
+
+ // The event that triggered this notification
+ Event *Event `json:"event"`
+
+ // Why this user is being notified
+ Reason NotificationReason `json:"reason"`
+
+ // Message content
+ Message string `json:"message"`
+
+ // Channel override (empty = DM)
+ Channel string `json:"channel,omitempty"`
+}
+
+// NotificationReason explains why a user is being notified
+type NotificationReason string
+
+const (
+ ReasonOwner NotificationReason = "owner" // PR/Issue author
+ ReasonAssignee NotificationReason = "assignee" // Assigned to PR/Issue
+ ReasonReviewer NotificationReason = "reviewer" // Requested reviewer
+ ReasonMention NotificationReason = "mention" // @mentioned in comment
+)
diff --git a/internal/identity/cache/cache.go b/internal/identity/cache/cache.go
new file mode 100644
index 0000000..e6e0924
--- /dev/null
+++ b/internal/identity/cache/cache.go
@@ -0,0 +1,124 @@
+package cache
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/rs/zerolog"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/event"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/identity"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/storage"
+)
+
+// CachedResolver implements identity.Resolver with caching
+// It stores resolved identities in the database and only queries
+// external APIs when a user is not found in the cache
+type CachedResolver struct {
+ repo storage.Repository
+ emailLookup identity.EmailLookup
+ slackLookup identity.SlackLookup
+ logger zerolog.Logger
+}
+
+// NewCachedResolver creates a new cached identity resolver
+func NewCachedResolver(
+ repo storage.Repository,
+ emailLookup identity.EmailLookup,
+ slackLookup identity.SlackLookup,
+ logger zerolog.Logger,
+) *CachedResolver {
+ return &CachedResolver{
+ repo: repo,
+ emailLookup: emailLookup,
+ slackLookup: slackLookup,
+ logger: logger.With().Str("component", "identity-resolver").Logger(),
+ }
+}
+
+// Resolve returns the external identity for a Gitea user
+// It follows this strategy:
+// 1. Check DB by Gitea username - if found with Slack ID, return cached result
+// 2. If not found, use Gitea API to get the real email (not the webhook email which may be noreply)
+// 3. Lookup Slack by email
+// 4. Cache the result for future lookups
+func (r *CachedResolver) Resolve(ctx context.Context, user event.User) (*identity.ResolvedIdentity, error) {
+ logger := r.logger.With().
+ Str("gitea_username", user.GiteaUsername).
+ Str("webhook_email", user.Email).
+ Logger()
+
+ // Step 1: Try to find by Gitea username in cache
+ if user.GiteaUsername != "" {
+ dbUser, err := r.repo.GetUserByGiteaUsername(ctx, user.GiteaUsername)
+ if err == nil && dbUser.SlackID != "" {
+ logger.Debug().
+ Str("cached_email", dbUser.Email).
+ Str("slack_id", dbUser.SlackID).
+ Msg("found user in cache by username")
+ return &identity.ResolvedIdentity{
+ Email: dbUser.Email,
+ SlackID: dbUser.SlackID,
+ SlackName: dbUser.SlackName,
+ }, nil
+ }
+ if err != nil && !storage.IsNotFound(err) {
+ logger.Error().Err(err).Msg("error querying user by username")
+ }
+ }
+
+ // Step 2: Not in cache - need to lookup real email via Gitea API
+ // We don't trust the webhook email because it might be a noreply address
+ logger.Info().Msg("user not in cache, querying Gitea API for real email")
+
+ if user.GiteaUsername == "" {
+ return nil, fmt.Errorf("no username available to lookup user")
+ }
+
+ if r.emailLookup == nil {
+ return nil, fmt.Errorf("no email lookup provider configured")
+ }
+
+ // Get real email from Gitea API
+ email, err := r.emailLookup.LookupEmail(ctx, user.GiteaUsername)
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to lookup email from Gitea API")
+ return nil, fmt.Errorf("looking up email for %s: %w", user.GiteaUsername, err)
+ }
+
+ logger.Info().Str("real_email", email).Msg("got real email from Gitea API")
+
+ // Step 3: Lookup Slack ID by real email
+ slackID, slackName, err := r.slackLookup.LookupSlackIDByEmail(ctx, email)
+ if err != nil {
+ logger.Warn().Err(err).Str("email", email).Msg("failed to lookup Slack ID")
+ return nil, fmt.Errorf("looking up Slack ID for %s: %w", email, err)
+ }
+
+ // Step 4: Cache the result
+ dbUser := &storage.User{
+ GiteaUsername: user.GiteaUsername,
+ GiteaID: user.GiteaID,
+ Email: strings.ToLower(email),
+ FullName: user.FullName,
+ SlackID: slackID,
+ SlackName: slackName,
+ }
+
+ if err := r.repo.UpsertUser(ctx, dbUser); err != nil {
+ logger.Error().Err(err).Msg("failed to cache user identity")
+ // Continue anyway, we have the identity
+ } else {
+ logger.Info().
+ Str("slack_id", slackID).
+ Str("slack_name", slackName).
+ Msg("cached user identity")
+ }
+
+ return &identity.ResolvedIdentity{
+ Email: email,
+ SlackID: slackID,
+ SlackName: slackName,
+ }, nil
+}
diff --git a/internal/identity/gitea/provider.go b/internal/identity/gitea/provider.go
new file mode 100644
index 0000000..a89631d
--- /dev/null
+++ b/internal/identity/gitea/provider.go
@@ -0,0 +1,125 @@
+package gitea
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/rs/zerolog"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/config"
+)
+
+// GiteaUser represents a user from Gitea API
+type GiteaUser struct {
+ ID int64 `json:"id"`
+ Login string `json:"login"`
+ FullName string `json:"full_name"`
+ Email string `json:"email"`
+ AvatarURL string `json:"avatar_url"`
+}
+
+// Provider implements email lookup via Gitea API
+type Provider struct {
+ baseURL string
+ token string
+ httpClient *http.Client
+ logger zerolog.Logger
+}
+
+// New creates a new Gitea API provider
+func New(cfg *config.GiteaConfig, logger zerolog.Logger) *Provider {
+ return &Provider{
+ baseURL: cfg.URL,
+ token: cfg.Token,
+ httpClient: &http.Client{
+ Timeout: 10 * time.Second,
+ },
+ logger: logger.With().Str("component", "gitea-provider").Logger(),
+ }
+}
+
+// LookupEmail fetches the real email for a Gitea username via API
+func (p *Provider) LookupEmail(ctx context.Context, username string) (string, error) {
+ url := fmt.Sprintf("%s/api/v1/users/%s", p.baseURL, username)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return "", fmt.Errorf("creating request: %w", err)
+ }
+
+ // Add authorization header if token is provided
+ if p.token != "" {
+ req.Header.Set("Authorization", "token "+p.token)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("fetching user from Gitea: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return "", fmt.Errorf("user %s not found in Gitea", username)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Gitea API returned status %d", resp.StatusCode)
+ }
+
+ var user GiteaUser
+ if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
+ return "", fmt.Errorf("decoding Gitea response: %w", err)
+ }
+
+ if user.Email == "" {
+ return "", fmt.Errorf("user %s has no email in Gitea", username)
+ }
+
+ p.logger.Debug().
+ Str("username", username).
+ Str("email", user.Email).
+ Str("full_name", user.FullName).
+ Msg("found user email via Gitea API")
+
+ return user.Email, nil
+}
+
+// GetUser fetches full user info from Gitea API
+func (p *Provider) GetUser(ctx context.Context, username string) (*GiteaUser, error) {
+ url := fmt.Sprintf("%s/api/v1/users/%s", p.baseURL, username)
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ if p.token != "" {
+ req.Header.Set("Authorization", "token "+p.token)
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("fetching user from Gitea: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == http.StatusNotFound {
+ return nil, fmt.Errorf("user %s not found in Gitea", username)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("Gitea API returned status %d", resp.StatusCode)
+ }
+
+ var user GiteaUser
+ if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
+ return nil, fmt.Errorf("decoding Gitea response: %w", err)
+ }
+
+ return &user, nil
+}
diff --git a/internal/identity/resolver.go b/internal/identity/resolver.go
new file mode 100644
index 0000000..801913a
--- /dev/null
+++ b/internal/identity/resolver.go
@@ -0,0 +1,33 @@
+package identity
+
+import (
+ "context"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/event"
+)
+
+// ResolvedIdentity represents a resolved external identity
+type ResolvedIdentity struct {
+ Email string `json:"email"`
+ SlackID string `json:"slack_id"`
+ SlackName string `json:"slack_name"`
+}
+
+// Resolver resolves Gitea users to external identities (e.g., Slack)
+// This interface allows for different identity providers (Gitea API, LDAP, etc.)
+type Resolver interface {
+ // Resolve returns the external identity for a Gitea user
+ Resolve(ctx context.Context, user event.User) (*ResolvedIdentity, error)
+}
+
+// EmailLookup provides email lookup functionality
+type EmailLookup interface {
+ // LookupEmail looks up a user's email address by username
+ LookupEmail(ctx context.Context, username string) (string, error)
+}
+
+// SlackLookup provides Slack user lookup functionality
+type SlackLookup interface {
+ // LookupSlackIDByEmail finds a Slack user ID by email
+ LookupSlackIDByEmail(ctx context.Context, email string) (slackID, slackName string, err error)
+}
diff --git a/internal/notifier/notifier.go b/internal/notifier/notifier.go
new file mode 100644
index 0000000..10833a2
--- /dev/null
+++ b/internal/notifier/notifier.go
@@ -0,0 +1,17 @@
+package notifier
+
+import (
+ "context"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/event"
+)
+
+// Notifier defines the interface for sending notifications
+// This abstraction allows for different notification providers (Slack, Teams, Discord, etc.)
+type Notifier interface {
+ // SendDirect sends a direct message to a user
+ SendDirect(ctx context.Context, userID string, msg *event.Notification) error
+
+ // SendChannel sends a message to a channel
+ SendChannel(ctx context.Context, channel string, msg *event.Notification) error
+}
diff --git a/internal/notifier/slack/slack.go b/internal/notifier/slack/slack.go
new file mode 100644
index 0000000..7ffd34a
--- /dev/null
+++ b/internal/notifier/slack/slack.go
@@ -0,0 +1,291 @@
+package slack
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/rs/zerolog"
+ "github.com/slack-go/slack"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/config"
+ "github.com/vincentc-afk/gitea-notification-hub/internal/event"
+)
+
+// Notifier implements notifier.Notifier and identity.SlackLookup using Slack API
+type Notifier struct {
+ client *slack.Client
+ defaultChannel string
+ logger zerolog.Logger
+}
+
+// New creates a new Slack notifier
+func New(cfg *config.SlackConfig, logger zerolog.Logger) *Notifier {
+ client := slack.New(cfg.BotToken)
+
+ return &Notifier{
+ client: client,
+ defaultChannel: cfg.DefaultChannel,
+ logger: logger.With().Str("component", "slack-notifier").Logger(),
+ }
+}
+
+// LookupSlackIDByEmail finds a Slack user ID by email address
+// This implements identity.SlackLookup interface
+func (n *Notifier) LookupSlackIDByEmail(ctx context.Context, email string) (slackID, slackName string, err error) {
+ user, err := n.client.GetUserByEmailContext(ctx, email)
+ if err != nil {
+ return "", "", fmt.Errorf("looking up Slack user by email %s: %w", email, err)
+ }
+
+ return user.ID, user.RealName, nil
+}
+
+// SendDirect sends a direct message to a Slack user
+func (n *Notifier) SendDirect(ctx context.Context, userID string, msg *event.Notification) error {
+ // Open a DM channel with the user
+ channel, _, _, err := n.client.OpenConversationContext(ctx, &slack.OpenConversationParameters{
+ Users: []string{userID},
+ })
+ if err != nil {
+ return fmt.Errorf("opening DM channel: %w", err)
+ }
+
+ // Build message blocks
+ blocks := n.buildMessageBlocks(msg)
+
+ // Send message
+ _, _, err = n.client.PostMessageContext(ctx,
+ channel.ID,
+ slack.MsgOptionBlocks(blocks...),
+ slack.MsgOptionText(msg.Message, false), // Fallback text
+ )
+ if err != nil {
+ return fmt.Errorf("sending DM: %w", err)
+ }
+
+ return nil
+}
+
+// SendChannel sends a message to a Slack channel
+func (n *Notifier) SendChannel(ctx context.Context, channel string, msg *event.Notification) error {
+ if channel == "" {
+ channel = n.defaultChannel
+ }
+
+ // Build message blocks
+ blocks := n.buildMessageBlocks(msg)
+
+ // Send message
+ _, _, err := n.client.PostMessageContext(ctx,
+ channel,
+ slack.MsgOptionBlocks(blocks...),
+ slack.MsgOptionText(msg.Message, false), // Fallback text
+ )
+ if err != nil {
+ return fmt.Errorf("sending channel message: %w", err)
+ }
+
+ return nil
+}
+
+// buildMessageBlocks creates Slack Block Kit message blocks
+func (n *Notifier) buildMessageBlocks(msg *event.Notification) []slack.Block {
+ e := msg.Event
+
+ // Header with emoji based on event type
+ emoji := n.getEventEmoji(e.Type)
+ headerText := fmt.Sprintf("%s %s", emoji, n.getEventTitle(e, msg.Reason))
+
+ headerBlock := slack.NewSectionBlock(
+ slack.NewTextBlockObject(slack.MarkdownType, headerText, false, false),
+ nil, nil,
+ )
+
+ // Repository and PR/Issue info
+ contextElements := []slack.MixedElement{
+ slack.NewTextBlockObject(slack.MarkdownType,
+ fmt.Sprintf("*<%s|%s>*", e.RepoURL, e.RepoFullName), false, false),
+ }
+
+ if e.Number > 0 {
+ contextElements = append(contextElements,
+ slack.NewTextBlockObject(slack.MarkdownType,
+ fmt.Sprintf("<%s|#%d %s>", e.URL, e.Number, truncate(e.Title, 50)), false, false))
+ }
+
+ contextBlock := slack.NewContextBlock("", contextElements...)
+
+ blocks := []slack.Block{headerBlock, contextBlock}
+
+ // Add comment body if present
+ if e.CommentBody != "" {
+ commentText := truncate(e.CommentBody, 300)
+ // Replace @mentions with Slack-style if we have them
+ commentBlock := slack.NewSectionBlock(
+ slack.NewTextBlockObject(slack.MarkdownType,
+ fmt.Sprintf(">>> %s", commentText), false, false),
+ nil, nil,
+ )
+ blocks = append(blocks, commentBlock)
+ }
+
+ // Add review state if present
+ if e.ReviewState != "" {
+ reviewEmoji := n.getReviewStateEmoji(e.ReviewState)
+ reviewBlock := slack.NewContextBlock("",
+ slack.NewTextBlockObject(slack.MarkdownType,
+ fmt.Sprintf("%s Review: *%s*", reviewEmoji, e.ReviewState), false, false))
+ blocks = append(blocks, reviewBlock)
+ }
+
+ // Add commits list if present (for synchronize events)
+ if len(e.Commits) > 0 {
+ var commitLines []string
+ maxCommits := 5 // Show max 5 commits
+ for i, c := range e.Commits {
+ if i >= maxCommits {
+ commitLines = append(commitLines, fmt.Sprintf("_... and %d more commits_", len(e.Commits)-maxCommits))
+ break
+ }
+ commitLines = append(commitLines, fmt.Sprintf("• `%s` %s", c.SHA, truncate(c.Message, 60)))
+ }
+ commitsText := strings.Join(commitLines, "\n")
+ commitsBlock := slack.NewSectionBlock(
+ slack.NewTextBlockObject(slack.MarkdownType, commitsText, false, false),
+ nil, nil,
+ )
+ blocks = append(blocks, commitsBlock)
+ }
+
+ // Add action button
+ buttonBlock := slack.NewActionBlock("actions",
+ slack.NewButtonBlockElement("view", e.URL,
+ slack.NewTextBlockObject(slack.PlainTextType, "View in Gitea", false, false)).
+ WithURL(e.URL),
+ )
+ blocks = append(blocks, buttonBlock)
+
+ return blocks
+}
+
+// getEventEmoji returns an emoji for the event type
+func (n *Notifier) getEventEmoji(eventType event.Type) string {
+ switch eventType {
+ case event.TypePROpened:
+ return ":git-pull-request:"
+ case event.TypePRClosed:
+ return ":git-pull-request-closed:"
+ case event.TypePRMerged:
+ return ":git-merge:"
+ case event.TypePRAssigned:
+ return ":bust_in_silhouette:"
+ case event.TypePRReviewRequested:
+ return ":eyes:"
+ case event.TypePRReviewed:
+ return ":memo:"
+ case event.TypePRCommented:
+ return ":speech_balloon:"
+ case event.TypePRSynchronized:
+ return ":arrows_counterclockwise:"
+ case event.TypeIssueOpened:
+ return ":issue-opened:"
+ case event.TypeIssueClosed:
+ return ":issue-closed:"
+ case event.TypeIssueCommented:
+ return ":speech_balloon:"
+ default:
+ return ":bell:"
+ }
+}
+
+// getEventTitle returns a human-readable title for the event
+func (n *Notifier) getEventTitle(e *event.Event, reason event.NotificationReason) string {
+ actorName := e.Actor.GiteaUsername
+ if e.Actor.FullName != "" {
+ actorName = e.Actor.FullName
+ }
+
+ switch e.Type {
+ case event.TypePROpened:
+ if reason == event.ReasonAssignee {
+ return fmt.Sprintf("*%s* assigned you to a pull request", actorName)
+ }
+ if reason == event.ReasonReviewer {
+ return fmt.Sprintf("*%s* requested your review on a pull request", actorName)
+ }
+ return fmt.Sprintf("*%s* opened a pull request", actorName)
+
+ case event.TypePRClosed:
+ return fmt.Sprintf("*%s* closed your pull request", actorName)
+
+ case event.TypePRMerged:
+ return fmt.Sprintf("*%s* merged your pull request", actorName)
+
+ case event.TypePRAssigned:
+ return fmt.Sprintf("*%s* assigned you to a pull request", actorName)
+
+ case event.TypePRReviewRequested:
+ return fmt.Sprintf("*%s* requested your review", actorName)
+
+ case event.TypePRReviewed:
+ return fmt.Sprintf("*%s* reviewed your pull request", actorName)
+
+ case event.TypePRCommented:
+ if reason == event.ReasonMention {
+ return fmt.Sprintf("*%s* mentioned you in a PR comment", actorName)
+ }
+ return fmt.Sprintf("*%s* commented on your pull request", actorName)
+
+ case event.TypePRSynchronized:
+ commitCount := len(e.Commits)
+ if commitCount == 1 {
+ return fmt.Sprintf("*%s* pushed 1 new commit to PR #%d", actorName, e.Number)
+ }
+ return fmt.Sprintf("*%s* pushed %d new commits to PR #%d", actorName, commitCount, e.Number)
+
+ case event.TypeIssueOpened:
+ if reason == event.ReasonAssignee {
+ return fmt.Sprintf("*%s* assigned you to an issue", actorName)
+ }
+ return fmt.Sprintf("*%s* opened an issue", actorName)
+
+ case event.TypeIssueClosed:
+ return fmt.Sprintf("*%s* closed your issue", actorName)
+
+ case event.TypeIssueCommented:
+ if reason == event.ReasonMention {
+ return fmt.Sprintf("*%s* mentioned you in an issue comment", actorName)
+ }
+ return fmt.Sprintf("*%s* commented on your issue", actorName)
+
+ default:
+ return fmt.Sprintf("New notification from *%s*", actorName)
+ }
+}
+
+// getReviewStateEmoji returns an emoji for the review state
+func (n *Notifier) getReviewStateEmoji(state string) string {
+ switch strings.ToUpper(state) {
+ case "APPROVED":
+ return ":white_check_mark:"
+ case "CHANGES_REQUESTED":
+ return ":x:"
+ case "COMMENT":
+ return ":speech_balloon:"
+ default:
+ return ":memo:"
+ }
+}
+
+// truncate truncates a string to the specified length
+func truncate(s string, maxLen int) string {
+ // Remove newlines for preview
+ s = strings.ReplaceAll(s, "\n", " ")
+ s = strings.ReplaceAll(s, "\r", "")
+
+ if len(s) <= maxLen {
+ return s
+ }
+ return s[:maxLen-3] + "..."
+}
diff --git a/internal/server/server.go b/internal/server/server.go
new file mode 100644
index 0000000..581503c
--- /dev/null
+++ b/internal/server/server.go
@@ -0,0 +1,97 @@
+package server
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+ "github.com/rs/zerolog"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/config"
+)
+
+// Server represents the HTTP server
+type Server struct {
+ cfg *config.Config
+ logger zerolog.Logger
+ router *chi.Mux
+ srv *http.Server
+}
+
+// New creates a new Server instance
+func New(cfg *config.Config, logger zerolog.Logger) *Server {
+ r := chi.NewRouter()
+
+ // Middleware
+ r.Use(middleware.RequestID)
+ r.Use(middleware.RealIP)
+ r.Use(middleware.Recoverer)
+ r.Use(requestLogger(logger))
+
+ s := &Server{
+ cfg: cfg,
+ logger: logger,
+ router: r,
+ }
+
+ // Health check endpoint
+ r.Get("/health", s.handleHealth)
+
+ return s
+}
+
+// Router returns the chi router for registering additional routes
+func (s *Server) Router() *chi.Mux {
+ return s.router
+}
+
+// Start starts the HTTP server
+func (s *Server) Start() error {
+ addr := fmt.Sprintf(":%d", s.cfg.Server.Port)
+ s.srv = &http.Server{
+ Addr: addr,
+ Handler: s.router,
+ ReadTimeout: 10 * time.Second,
+ WriteTimeout: 10 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ s.logger.Info().Str("addr", addr).Msg("starting HTTP server")
+ return s.srv.ListenAndServe()
+}
+
+// Shutdown gracefully shuts down the server
+func (s *Server) Shutdown(ctx context.Context) error {
+ s.logger.Info().Msg("shutting down HTTP server")
+ return s.srv.Shutdown(ctx)
+}
+
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"status":"ok"}`))
+}
+
+// requestLogger returns a middleware that logs HTTP requests
+func requestLogger(logger zerolog.Logger) func(next http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
+
+ defer func() {
+ logger.Info().
+ Str("method", r.Method).
+ Str("path", r.URL.Path).
+ Int("status", ww.Status()).
+ Dur("duration", time.Since(start)).
+ Msg("request")
+ }()
+
+ next.ServeHTTP(ww, r)
+ })
+ }
+}
diff --git a/internal/storage/models.go b/internal/storage/models.go
new file mode 100644
index 0000000..ae50ab6
--- /dev/null
+++ b/internal/storage/models.go
@@ -0,0 +1,16 @@
+package storage
+
+import "time"
+
+// User represents a user with identity mappings
+type User struct {
+ ID int64 `json:"id"`
+ GiteaUsername string `json:"gitea_username"`
+ GiteaID int64 `json:"gitea_id"`
+ Email string `json:"email"`
+ FullName string `json:"full_name"`
+ SlackID string `json:"slack_id"`
+ SlackName string `json:"slack_name"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
diff --git a/internal/storage/repository.go b/internal/storage/repository.go
new file mode 100644
index 0000000..47cf2da
--- /dev/null
+++ b/internal/storage/repository.go
@@ -0,0 +1,35 @@
+package storage
+
+import "context"
+
+// Repository defines the interface for user storage operations
+// This abstraction allows swapping SQLite for PostgreSQL or other databases
+type Repository interface {
+ // User operations
+ GetUserByEmail(ctx context.Context, email string) (*User, error)
+ GetUserByGiteaUsername(ctx context.Context, username string) (*User, error)
+ GetUserBySlackID(ctx context.Context, slackID string) (*User, error)
+ UpsertUser(ctx context.Context, user *User) error
+ ListUsers(ctx context.Context) ([]*User, error)
+
+ // Migrations
+ Migrate(ctx context.Context) error
+
+ // Close the connection
+ Close() error
+}
+
+// ErrNotFound is returned when a user is not found
+type ErrNotFound struct {
+ Message string
+}
+
+func (e *ErrNotFound) Error() string {
+ return e.Message
+}
+
+// IsNotFound checks if an error is a not found error
+func IsNotFound(err error) bool {
+ _, ok := err.(*ErrNotFound)
+ return ok
+}
diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go
new file mode 100644
index 0000000..8871b28
--- /dev/null
+++ b/internal/storage/sqlite/sqlite.go
@@ -0,0 +1,230 @@
+package sqlite
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ _ "github.com/mattn/go-sqlite3"
+
+ "github.com/vincentc-afk/gitea-notification-hub/internal/storage"
+)
+
+// Repository implements storage.Repository using SQLite
+type Repository struct {
+ db *sql.DB
+}
+
+// New creates a new SQLite repository
+func New(dsn string) (*Repository, error) {
+ // Ensure directory exists
+ dir := filepath.Dir(dsn)
+ if dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("creating data directory: %w", err)
+ }
+ }
+
+ db, err := sql.Open("sqlite3", dsn+"?_foreign_keys=on&_journal_mode=WAL")
+ if err != nil {
+ return nil, fmt.Errorf("opening database: %w", err)
+ }
+
+ // Test connection
+ if err := db.Ping(); err != nil {
+ return nil, fmt.Errorf("pinging database: %w", err)
+ }
+
+ return &Repository{db: db}, nil
+}
+
+// Migrate runs database migrations
+func (r *Repository) Migrate(ctx context.Context) error {
+ schema := `
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ gitea_username TEXT UNIQUE,
+ gitea_id INTEGER,
+ email TEXT UNIQUE,
+ full_name TEXT,
+ slack_id TEXT,
+ slack_name TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+ CREATE INDEX IF NOT EXISTS idx_users_gitea_username ON users(gitea_username);
+ CREATE INDEX IF NOT EXISTS idx_users_slack_id ON users(slack_id);
+ `
+
+ _, err := r.db.ExecContext(ctx, schema)
+ if err != nil {
+ return fmt.Errorf("running migrations: %w", err)
+ }
+
+ return nil
+}
+
+// GetUserByEmail retrieves a user by email
+func (r *Repository) GetUserByEmail(ctx context.Context, email string) (*storage.User, error) {
+ query := `
+ SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
+ FROM users WHERE email = ?
+ `
+
+ var user storage.User
+ err := r.db.QueryRowContext(ctx, query, email).Scan(
+ &user.ID,
+ &user.GiteaUsername,
+ &user.GiteaID,
+ &user.Email,
+ &user.FullName,
+ &user.SlackID,
+ &user.SlackName,
+ &user.CreatedAt,
+ &user.UpdatedAt,
+ )
+
+ if err == sql.ErrNoRows {
+ return nil, &storage.ErrNotFound{Message: fmt.Sprintf("user with email %s not found", email)}
+ }
+ if err != nil {
+ return nil, fmt.Errorf("querying user by email: %w", err)
+ }
+
+ return &user, nil
+}
+
+// GetUserByGiteaUsername retrieves a user by Gitea username
+func (r *Repository) GetUserByGiteaUsername(ctx context.Context, username string) (*storage.User, error) {
+ query := `
+ SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
+ FROM users WHERE gitea_username = ?
+ `
+
+ var user storage.User
+ err := r.db.QueryRowContext(ctx, query, username).Scan(
+ &user.ID,
+ &user.GiteaUsername,
+ &user.GiteaID,
+ &user.Email,
+ &user.FullName,
+ &user.SlackID,
+ &user.SlackName,
+ &user.CreatedAt,
+ &user.UpdatedAt,
+ )
+
+ if err == sql.ErrNoRows {
+ return nil, &storage.ErrNotFound{Message: fmt.Sprintf("user with username %s not found", username)}
+ }
+ if err != nil {
+ return nil, fmt.Errorf("querying user by username: %w", err)
+ }
+
+ return &user, nil
+}
+
+// GetUserBySlackID retrieves a user by Slack ID
+func (r *Repository) GetUserBySlackID(ctx context.Context, slackID string) (*storage.User, error) {
+ query := `
+ SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
+ FROM users WHERE slack_id = ?
+ `
+
+ var user storage.User
+ err := r.db.QueryRowContext(ctx, query, slackID).Scan(
+ &user.ID,
+ &user.GiteaUsername,
+ &user.GiteaID,
+ &user.Email,
+ &user.FullName,
+ &user.SlackID,
+ &user.SlackName,
+ &user.CreatedAt,
+ &user.UpdatedAt,
+ )
+
+ if err == sql.ErrNoRows {
+ return nil, &storage.ErrNotFound{Message: fmt.Sprintf("user with slack_id %s not found", slackID)}
+ }
+ if err != nil {
+ return nil, fmt.Errorf("querying user by slack_id: %w", err)
+ }
+
+ return &user, nil
+}
+
+// UpsertUser inserts or updates a user
+func (r *Repository) UpsertUser(ctx context.Context, user *storage.User) error {
+ query := `
+ INSERT INTO users (gitea_username, gitea_id, email, full_name, slack_id, slack_name, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(email) DO UPDATE SET
+ gitea_username = COALESCE(excluded.gitea_username, users.gitea_username),
+ gitea_id = COALESCE(excluded.gitea_id, users.gitea_id),
+ full_name = COALESCE(excluded.full_name, users.full_name),
+ slack_id = COALESCE(excluded.slack_id, users.slack_id),
+ slack_name = COALESCE(excluded.slack_name, users.slack_name),
+ updated_at = excluded.updated_at
+ `
+
+ _, err := r.db.ExecContext(ctx, query,
+ user.GiteaUsername,
+ user.GiteaID,
+ user.Email,
+ user.FullName,
+ user.SlackID,
+ user.SlackName,
+ time.Now(),
+ )
+ if err != nil {
+ return fmt.Errorf("upserting user: %w", err)
+ }
+
+ return nil
+}
+
+// ListUsers returns all users
+func (r *Repository) ListUsers(ctx context.Context) ([]*storage.User, error) {
+ query := `
+ SELECT id, gitea_username, gitea_id, email, full_name, slack_id, slack_name, created_at, updated_at
+ FROM users ORDER BY created_at DESC
+ `
+
+ rows, err := r.db.QueryContext(ctx, query)
+ if err != nil {
+ return nil, fmt.Errorf("querying users: %w", err)
+ }
+ defer rows.Close()
+
+ var users []*storage.User
+ for rows.Next() {
+ var user storage.User
+ if err := rows.Scan(
+ &user.ID,
+ &user.GiteaUsername,
+ &user.GiteaID,
+ &user.Email,
+ &user.FullName,
+ &user.SlackID,
+ &user.SlackName,
+ &user.CreatedAt,
+ &user.UpdatedAt,
+ ); err != nil {
+ return nil, fmt.Errorf("scanning user: %w", err)
+ }
+ users = append(users, &user)
+ }
+
+ return users, nil
+}
+
+// Close closes the database connection
+func (r *Repository) Close() error {
+ return r.db.Close()
+}
diff --git a/internal/webhook/events.go b/internal/webhook/events.go
new file mode 100644
index 0000000..f48e31e
--- /dev/null
+++ b/internal/webhook/events.go
@@ -0,0 +1,146 @@
+package webhook
+
+import "time"
+
+// GiteaEventType represents the type of Gitea webhook event
+type GiteaEventType string
+
+const (
+ EventPullRequest GiteaEventType = "pull_request"
+ EventPullRequestReview GiteaEventType = "pull_request_review"
+ EventPullRequestComment GiteaEventType = "pull_request_comment"
+ EventIssues GiteaEventType = "issues"
+ EventIssueComment GiteaEventType = "issue_comment"
+)
+
+// GiteaUser represents a Gitea user in webhook payloads
+type GiteaUser struct {
+ ID int64 `json:"id"`
+ Login string `json:"login"`
+ FullName string `json:"full_name"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+}
+
+// Repository represents a Gitea repository
+type Repository struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ FullName string `json:"full_name"`
+ HTMLURL string `json:"html_url"`
+}
+
+// PullRequest represents a Gitea pull request
+type PullRequest struct {
+ ID int64 `json:"id"`
+ Number int64 `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ HTMLURL string `json:"html_url"`
+ User GiteaUser `json:"user"`
+ Assignees []GiteaUser `json:"assignees"`
+ RequestedReviewers []GiteaUser `json:"requested_reviewers"`
+ Merged bool `json:"merged"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Issue represents a Gitea issue
+type Issue struct {
+ ID int64 `json:"id"`
+ Number int64 `json:"number"`
+ Title string `json:"title"`
+ Body string `json:"body"`
+ State string `json:"state"`
+ HTMLURL string `json:"html_url"`
+ User GiteaUser `json:"user"`
+ Assignees []GiteaUser `json:"assignees"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Comment represents a comment on PR or Issue
+type Comment struct {
+ ID int64 `json:"id"`
+ Body string `json:"body"`
+ HTMLURL string `json:"html_url"`
+ User GiteaUser `json:"user"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// Review represents a PR review
+type Review struct {
+ ID int64 `json:"id"`
+ Body string `json:"body"`
+ State string `json:"state"` // APPROVED, CHANGES_REQUESTED, COMMENT
+ HTMLURL string `json:"html_url"`
+ User GiteaUser `json:"user"`
+ SubmittedAt time.Time `json:"submitted_at"`
+}
+
+// Commit represents a Git commit in webhook payloads
+type Commit struct {
+ ID string `json:"id"` // SHA
+ Message string `json:"message"`
+ URL string `json:"url"`
+ Author GitUser `json:"author"`
+ Committer GitUser `json:"committer"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+// GitUser represents a git author/committer (different from GiteaUser)
+type GitUser struct {
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Username string `json:"username"`
+}
+
+// PullRequestEvent is the payload for pull_request webhooks
+type PullRequestEvent struct {
+ Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, review_requested, synchronize, etc.
+ Number int64 `json:"number"`
+ PullRequest PullRequest `json:"pull_request"`
+ Repository Repository `json:"repository"`
+ Sender GiteaUser `json:"sender"`
+ RequestedReviewers []GiteaUser `json:"requested_reviewers"`
+ RequestedReviewer *GiteaUser `json:"requested_reviewer"` // Present on review_requested action (singular)
+ Assignee *GiteaUser `json:"assignee"` // Present on assigned/unassigned action
+ Commits []Commit `json:"commits"` // Present on synchronize action
+}
+
+// PullRequestReviewEvent is the payload for pull_request_review webhooks
+type PullRequestReviewEvent struct {
+ Action string `json:"action"` // submitted
+ Review Review `json:"review"`
+ PullRequest PullRequest `json:"pull_request"`
+ Repository Repository `json:"repository"`
+ Sender GiteaUser `json:"sender"`
+}
+
+// PullRequestCommentEvent is the payload for pull_request_comment webhooks
+type PullRequestCommentEvent struct {
+ Action string `json:"action"` // created, edited, deleted
+ Comment Comment `json:"comment"`
+ PullRequest PullRequest `json:"pull_request"`
+ Repository Repository `json:"repository"`
+ Sender GiteaUser `json:"sender"`
+}
+
+// IssueEvent is the payload for issues webhooks
+type IssueEvent struct {
+ Action string `json:"action"` // opened, closed, reopened, edited, assigned, unassigned, etc.
+ Issue Issue `json:"issue"`
+ Repository Repository `json:"repository"`
+ Sender GiteaUser `json:"sender"`
+}
+
+// IssueCommentEvent is the payload for issue_comment webhooks
+type IssueCommentEvent struct {
+ Action string `json:"action"` // created, edited, deleted
+ Comment Comment `json:"comment"`
+ Issue Issue `json:"issue"`
+ Repository Repository `json:"repository"`
+ Sender GiteaUser `json:"sender"`
+}
diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go
new file mode 100644
index 0000000..bf6cf76
--- /dev/null
+++ b/internal/webhook/handler.go
@@ -0,0 +1,137 @@
+package webhook
+
+import (
+ "encoding/json"
+ "net/http"
+
+ "github.com/rs/zerolog"
+)
+
+// EventHandler processes webhook events
+type EventHandler interface {
+ HandlePullRequest(event *PullRequestEvent)
+ HandlePullRequestReview(event *PullRequestReviewEvent)
+ HandlePullRequestComment(event *PullRequestCommentEvent)
+ HandleIssue(event *IssueEvent)
+ HandleIssueComment(event *IssueCommentEvent)
+}
+
+// Handler handles incoming Gitea webhooks
+type Handler struct {
+ validator *Validator
+ eventHandler EventHandler
+ logger zerolog.Logger
+}
+
+// NewHandler creates a new webhook handler
+func NewHandler(secret string, eventHandler EventHandler, logger zerolog.Logger) *Handler {
+ return &Handler{
+ validator: NewValidator(secret),
+ eventHandler: eventHandler,
+ logger: logger.With().Str("component", "webhook").Logger(),
+ }
+}
+
+// ServeHTTP handles the webhook HTTP request
+func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Only accept POST requests
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Get event metadata
+ eventType := GetEventType(r)
+ deliveryID := GetDeliveryID(r)
+
+ logger := h.logger.With().
+ Str("delivery_id", deliveryID).
+ Str("event_type", string(eventType)).
+ Logger()
+
+ // Validate signature and read body
+ body, err := h.validator.ValidateRequest(r)
+ if err != nil {
+ logger.Warn().Err(err).Msg("webhook validation failed")
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ // Respond immediately with 200 OK
+ // Process the event asynchronously
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"status":"accepted"}`))
+
+ // Process the event in a goroutine
+ go h.processEvent(logger, eventType, body)
+}
+
+// processEvent parses and routes the event to the appropriate handler
+func (h *Handler) processEvent(logger zerolog.Logger, eventType GiteaEventType, body []byte) {
+ var err error
+
+ switch eventType {
+ case EventPullRequest:
+ var event PullRequestEvent
+ if err = json.Unmarshal(body, &event); err == nil {
+ logger.Info().
+ Str("action", event.Action).
+ Int64("pr_number", event.Number).
+ Str("repo", event.Repository.FullName).
+ Msg("processing pull request event")
+ h.eventHandler.HandlePullRequest(&event)
+ }
+
+ case EventPullRequestReview:
+ var event PullRequestReviewEvent
+ if err = json.Unmarshal(body, &event); err == nil {
+ logger.Info().
+ Str("action", event.Action).
+ Str("review_state", event.Review.State).
+ Str("repo", event.Repository.FullName).
+ Msg("processing pull request review event")
+ h.eventHandler.HandlePullRequestReview(&event)
+ }
+
+ case EventPullRequestComment:
+ var event PullRequestCommentEvent
+ if err = json.Unmarshal(body, &event); err == nil {
+ logger.Info().
+ Str("action", event.Action).
+ Int64("pr_number", event.PullRequest.Number).
+ Str("repo", event.Repository.FullName).
+ Msg("processing pull request comment event")
+ h.eventHandler.HandlePullRequestComment(&event)
+ }
+
+ case EventIssues:
+ var event IssueEvent
+ if err = json.Unmarshal(body, &event); err == nil {
+ logger.Info().
+ Str("action", event.Action).
+ Int64("issue_number", event.Issue.Number).
+ Str("repo", event.Repository.FullName).
+ Msg("processing issue event")
+ h.eventHandler.HandleIssue(&event)
+ }
+
+ case EventIssueComment:
+ var event IssueCommentEvent
+ if err = json.Unmarshal(body, &event); err == nil {
+ logger.Info().
+ Str("action", event.Action).
+ Int64("issue_number", event.Issue.Number).
+ Str("repo", event.Repository.FullName).
+ Msg("processing issue comment event")
+ h.eventHandler.HandleIssueComment(&event)
+ }
+
+ default:
+ logger.Debug().Msg("ignoring unknown event type")
+ return
+ }
+
+ if err != nil {
+ logger.Error().Err(err).Msg("failed to parse event payload")
+ }
+}
diff --git a/internal/webhook/validator.go b/internal/webhook/validator.go
new file mode 100644
index 0000000..a0558eb
--- /dev/null
+++ b/internal/webhook/validator.go
@@ -0,0 +1,75 @@
+package webhook
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+)
+
+var (
+ ErrMissingSignature = errors.New("missing X-Gitea-Signature header")
+ ErrInvalidSignature = errors.New("invalid webhook signature")
+)
+
+// Validator validates Gitea webhook signatures
+type Validator struct {
+ secret []byte
+}
+
+// NewValidator creates a new webhook validator with the given secret
+func NewValidator(secret string) *Validator {
+ return &Validator{
+ secret: []byte(secret),
+ }
+}
+
+// ValidateRequest validates the signature of an incoming webhook request
+// It returns the request body if valid, or an error if validation fails
+func (v *Validator) ValidateRequest(r *http.Request) ([]byte, error) {
+ // Read the body
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ // Get signature from header
+ signature := r.Header.Get("X-Gitea-Signature")
+ if signature == "" {
+ return nil, ErrMissingSignature
+ }
+
+ // Validate signature
+ if !v.validateSignature(body, signature) {
+ return nil, ErrInvalidSignature
+ }
+
+ return body, nil
+}
+
+// validateSignature checks if the HMAC-SHA256 signature matches
+func (v *Validator) validateSignature(payload []byte, signature string) bool {
+ // Gitea sends the signature as a hex-encoded HMAC-SHA256
+ mac := hmac.New(sha256.New, v.secret)
+ mac.Write(payload)
+ expectedMAC := mac.Sum(nil)
+ expectedSignature := hex.EncodeToString(expectedMAC)
+
+ // Handle both with and without "sha256=" prefix
+ signature = strings.TrimPrefix(signature, "sha256=")
+
+ return hmac.Equal([]byte(signature), []byte(expectedSignature))
+}
+
+// GetEventType extracts the event type from the request headers
+func GetEventType(r *http.Request) GiteaEventType {
+ return GiteaEventType(r.Header.Get("X-Gitea-Event"))
+}
+
+// GetDeliveryID extracts the unique delivery ID from the request headers
+func GetDeliveryID(r *http.Request) string {
+ return r.Header.Get("X-Gitea-Delivery")
+}
diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql
new file mode 100644
index 0000000..7d21fa3
--- /dev/null
+++ b/migrations/001_initial.sql
@@ -0,0 +1,18 @@
+-- Initial schema for Gitea Notification Hub
+-- This file is for documentation purposes; migrations are run programmatically
+
+CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ gitea_username TEXT UNIQUE,
+ gitea_id INTEGER,
+ email TEXT UNIQUE,
+ full_name TEXT,
+ slack_id TEXT,
+ slack_name TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
+CREATE INDEX IF NOT EXISTS idx_users_gitea_username ON users(gitea_username);
+CREATE INDEX IF NOT EXISTS idx_users_slack_id ON users(slack_id);