From 6bf27aa40e4bc08f7f556c3b46d96cc7793e6ee6 Mon Sep 17 00:00:00 2001 From: Dimitris Karvounis Date: Mon, 22 Jun 2026 11:56:10 +0300 Subject: [PATCH] Initial import with modified Dockerfile for env-based config generation --- Dockerfile | 52 +++ LICENSE | 661 ++++++++++++++++++++++++++++ cmd/server/main.go | 138 ++++++ config/config.example.yaml | 42 ++ docker-compose.yml | 23 + docker-entrypoint.sh | 45 ++ go.mod | 24 + go.sum | 49 +++ internal/config/config.go | 100 +++++ internal/event/mention.go | 86 ++++ internal/event/processor.go | 564 ++++++++++++++++++++++++ internal/event/types.go | 100 +++++ internal/identity/cache/cache.go | 124 ++++++ internal/identity/gitea/provider.go | 125 ++++++ internal/identity/resolver.go | 33 ++ internal/notifier/notifier.go | 17 + internal/notifier/slack/slack.go | 291 ++++++++++++ internal/server/server.go | 97 ++++ internal/storage/models.go | 16 + internal/storage/repository.go | 35 ++ internal/storage/sqlite/sqlite.go | 230 ++++++++++ internal/webhook/events.go | 146 ++++++ internal/webhook/handler.go | 137 ++++++ internal/webhook/validator.go | 75 ++++ migrations/001_initial.sql | 18 + 25 files changed, 3228 insertions(+) create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 cmd/server/main.go create mode 100644 config/config.example.yaml create mode 100644 docker-compose.yml create mode 100755 docker-entrypoint.sh create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/event/mention.go create mode 100644 internal/event/processor.go create mode 100644 internal/event/types.go create mode 100644 internal/identity/cache/cache.go create mode 100644 internal/identity/gitea/provider.go create mode 100644 internal/identity/resolver.go create mode 100644 internal/notifier/notifier.go create mode 100644 internal/notifier/slack/slack.go create mode 100644 internal/server/server.go create mode 100644 internal/storage/models.go create mode 100644 internal/storage/repository.go create mode 100644 internal/storage/sqlite/sqlite.go create mode 100644 internal/webhook/events.go create mode 100644 internal/webhook/handler.go create mode 100644 internal/webhook/validator.go create mode 100644 migrations/001_initial.sql 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);