├── .all-contributorsrc ├── .dockerignore ├── .github ├── ci-reporter.yml ├── semantic.yml └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── app.py ├── bot ├── __init__.py ├── github │ ├── __init__.py │ ├── app.py │ ├── authenticator.py │ ├── base.py │ └── parser.py ├── models │ ├── __init__.py │ ├── github │ │ ├── __init__.py │ │ ├── commit.py │ │ ├── event.py │ │ ├── event_type.py │ │ ├── issue.py │ │ ├── pull_request.py │ │ ├── ref.py │ │ ├── repository.py │ │ └── user.py │ ├── link.py │ └── slack.py ├── slack │ ├── __init__.py │ ├── base.py │ ├── bot.py │ ├── messenger.py │ ├── runner.py │ └── templates.py ├── storage │ ├── __init__.py │ ├── github.py │ └── subscriptions.py ├── utils │ ├── __init__.py │ ├── json.py │ ├── list_manip.py │ └── log.py └── views.py ├── docker-compose.yml ├── requirements.txt ├── samples ├── .env ├── .env.dev └── bot_manifest.yml ├── scripts ├── change_dev_url.py └── setup_linux.sh └── tests ├── __init__.py ├── github ├── __init__.py ├── data.json └── test_parser.py ├── integration └── __init__.py ├── mocks ├── __init__.py ├── slack │ ├── __init__.py │ ├── base.py │ └── runner.py └── storage │ ├── __init__.py │ └── subscriptions.py ├── slack ├── __init__.py ├── data.json └── test_runner.py ├── test_utils ├── comparators.py ├── deserializers.py ├── load.py └── serializers.py └── utils ├── __init__.py └── test_json.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat-square)](#contributors)", 3 | "contributorsPerLine": 7, 4 | "contributorsSortAlphabetically": true, 5 | "files": [ 6 | "README.md" 7 | ], 8 | "imageSize": 100, 9 | "projectName": "github-slack-bot", 10 | "projectOwner": "BURG3R5", 11 | "repoHost": "https://github.com", 12 | "repoType": "github", 13 | "skipCi": false, 14 | "contributors": [ 15 | { 16 | "login": "Sickaada", 17 | "name": "Madhur Rao", 18 | "avatar_url": "https://avatars.githubusercontent.com/u/61564567?v=4", 19 | "profile": "https://github.com/Sickaada", 20 | "contributions": [ 21 | "mentoring", 22 | "review", 23 | "projectManagement" 24 | ] 25 | }, 26 | { 27 | "login": "srinjoyghosh-bot", 28 | "name": "srinjoyghosh-bot", 29 | "avatar_url": "https://avatars.githubusercontent.com/u/76196327?v=4", 30 | "profile": "https://github.com/srinjoyghosh-bot", 31 | "contributions": [ 32 | "code" 33 | ] 34 | }, 35 | { 36 | "login": "BURG3R5", 37 | "name": "BURG3R5", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/77491630?v=4", 39 | "profile": "https://github.com/BURG3R5", 40 | "contributions": [ 41 | "code", 42 | "maintenance", 43 | "review", 44 | "projectManagement" 45 | ] 46 | }, 47 | { 48 | "login": "Magnesium12", 49 | "name": "Magnesium12", 50 | "avatar_url": "https://avatars.githubusercontent.com/u/99383854?v=4", 51 | "profile": "https://github.com/Magnesium12", 52 | "contributions": [ 53 | "code", 54 | "test" 55 | ] 56 | }, 57 | { 58 | "login": "shashank-k-y", 59 | "name": "Shashank", 60 | "avatar_url": "https://avatars.githubusercontent.com/u/74789167?v=4", 61 | "profile": "https://github.com/shashank-k-y", 62 | "contributions": [ 63 | "code" 64 | ] 65 | }, 66 | { 67 | "login": "Ayush0Chaudhary", 68 | "name": "Ayush Chaudhary", 69 | "avatar_url": "https://avatars.githubusercontent.com/u/95746190?v=4", 70 | "profile": "https://github.com/Ayush0Chaudhary", 71 | "contributions": [ 72 | "code", 73 | "test" 74 | ] 75 | } 76 | ] 77 | } 78 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore everything 2 | * 3 | 4 | # Except these directories 5 | !/bot 6 | 7 | # And these files 8 | !/main.py 9 | !/requirements.txt 10 | !/.env 11 | -------------------------------------------------------------------------------- /.github/ci-reporter.yml: -------------------------------------------------------------------------------- 1 | updateComment: false 2 | 3 | before: "CI failed with output:" 4 | 5 | after: false 6 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | allowMergeCommits: true 2 | titleAndCommits: true 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push,pull_request] 3 | jobs: 4 | check-formatting: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout code 8 | uses: actions/checkout@v2 9 | - name: Setup Python 10 | uses: actions/setup-python@v2 11 | with: 12 | python-version: '3.10' 13 | - name: Install and run pre-commit hooks 14 | run: | 15 | pip install pre-commit 16 | pre-commit run --all-files 17 | run-tests: 18 | runs-on: ubuntu-latest 19 | needs: check-formatting 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v2 23 | - name: Setup Python 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: '3.10' 27 | - name: Setup environment 28 | run: | 29 | cp samples/.env.dev .env 30 | mkdir data 31 | - name: Install dependencies 32 | run: pip install -r requirements.txt 33 | - name: Run tests 34 | run: python -m unittest -v 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Singletons 2 | /.env 3 | 4 | # IDE config folders 5 | .idea 6 | .vscode 7 | 8 | # Virtual environment folders 9 | .venv 10 | venv 11 | 12 | # Storage 13 | data 14 | 15 | # Temporary files 16 | __pycache__/ 17 | *.pyc 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.3.0 4 | hooks: 5 | - id: check-json 6 | - id: check-yaml 7 | - id: end-of-file-fixer 8 | - id: requirements-txt-fixer 9 | - id: trailing-whitespace 10 | - repo: https://github.com/pycqa/isort 11 | rev: 5.10.1 12 | hooks: 13 | - id: isort 14 | args: [ "--profile", "black", "--filter-files" ] 15 | - repo: https://github.com/pre-commit/mirrors-yapf 16 | rev: v0.32.0 17 | hooks: 18 | - id: yapf 19 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10-alpine 2 | 3 | WORKDIR /selene 4 | 5 | COPY requirements.txt requirements.txt 6 | RUN pip3 install -r requirements.txt 7 | 8 | COPY . . 9 | 10 | CMD ["flask", "run"] 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://img.shields.io/github/actions/workflow/status/BURG3R5/github-slack-bot/ci.yml?branch=master&style=flat-square)](https://github.com/BURG3R5/github-slack-bot/actions/workflows/ci.yml) 2 | [![slack](https://img.shields.io/badge/slack-github--slack--bot-lightgrey?logo=slack&style=flat-square)](https://join.slack.com/t/github-slack-bot/shared_invite/zt-1ebtvtdfr-3bPrsDDBnL95hW1pIjivbw) 3 | 4 | [![All Contributors](https://img.shields.io/badge/all_contributors-6-orange.svg?style=flat-square)](#contributors) 5 | 6 | 7 | # Selene 8 | 9 | Concisely and precisely informs users of events in a GitHub org. 10 | 11 | ### Features 12 | 13 | This bot has 14 | 15 | - More specific events, and 16 | - Less verbose messages 17 | 18 | than the official GitHub-Slack integration. 19 | 20 | ### [Installation](https://github.com/BURG3R5/github-slack-bot/wiki/Installation) 21 | 22 | ### [Setup for Development](https://github.com/BURG3R5/github-slack-bot/wiki/Setup-for-Development) 23 | 24 | ### Contributors ✨ 25 | 26 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
Ayush Chaudhary
Ayush Chaudhary

💻 ⚠️
BURG3R5
BURG3R5

💻 🚧 👀 📆
Madhur Rao
Madhur Rao

🧑‍🏫 👀 📆
Magnesium12
Magnesium12

💻 ⚠️
Shashank
Shashank

💻
srinjoyghosh-bot
srinjoyghosh-bot

💻
46 | 47 | 48 | 49 | 50 | 51 | 52 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 53 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Execution entrypoint for the project. 3 | 4 | Sets up a `Flask` server with three endpoints: "/", "/github/events" and "/slack/commands". 5 | 6 | "/" is used for testing and status checks. 7 | 8 | "/github/events" is provided to GitHub Webhooks to POST event info at. 9 | Triggers `manage_github_events` which uses `GitHubApp.parse` and `SlackBot.inform`. 10 | 11 | "/slack/commands" is provided to Slack to POST slash command info at. 12 | Triggers `manage_slack_commands` which uses `SlackBot.run`. 13 | """ 14 | 15 | import os 16 | from pathlib import Path 17 | from typing import Any, Optional, Union 18 | 19 | import sentry_sdk 20 | from dotenv import load_dotenv 21 | from flask import Flask, make_response, request 22 | from sentry_sdk.integrations.flask import FlaskIntegration 23 | 24 | from bot import views 25 | from bot.github import GitHubApp 26 | from bot.models.github.event import GitHubEvent 27 | from bot.slack import SlackBot 28 | from bot.slack.templates import error_message 29 | from bot.utils.log import Logger 30 | 31 | load_dotenv(Path(".") / ".env") 32 | 33 | debug = os.environ["FLASK_DEBUG"] == "1" 34 | 35 | if (not debug) and ("SENTRY_DSN" in os.environ): 36 | sentry_sdk.init( 37 | dsn=os.environ["SENTRY_DSN"], 38 | integrations=[FlaskIntegration()], 39 | ) 40 | 41 | slack_bot = SlackBot( 42 | token=os.environ["SLACK_OAUTH_TOKEN"], 43 | logger=Logger(int(os.environ.get("LOG_LAST_N_COMMANDS", 100))), 44 | base_url=os.environ["BASE_URL"], 45 | secret=os.environ["SLACK_SIGNING_SECRET"], 46 | bot_id=os.environ["SLACK_BOT_ID"], 47 | ) 48 | 49 | github_app = GitHubApp( 50 | base_url=os.environ["BASE_URL"], 51 | client_id=os.environ["GITHUB_APP_CLIENT_ID"], 52 | client_secret=os.environ["GITHUB_APP_CLIENT_SECRET"], 53 | ) 54 | 55 | app = Flask(__name__) 56 | 57 | app.add_url_rule("/", view_func=views.test_get) 58 | 59 | 60 | @app.route("/github/events", methods=['POST']) 61 | def manage_github_events(): 62 | """ 63 | Uses `GitHubApp` to verify, parse and cast the payload into a `GitHubEvent`. 64 | Then uses an instance of `SlackBot` to send appropriate messages to appropriate channels. 65 | """ 66 | 67 | is_valid_request, message = github_app.verify(request) 68 | if not is_valid_request: 69 | return make_response(message, 400) 70 | 71 | event: Optional[GitHubEvent] = github_app.parse( 72 | event_type=request.headers["X-GitHub-Event"], 73 | raw_json=request.json, 74 | ) 75 | 76 | if event is not None: 77 | slack_bot.inform(event) 78 | return "Informed appropriate channels" 79 | 80 | return "Unrecognized Event" 81 | 82 | 83 | @app.route("/slack/commands", methods=['POST']) 84 | def manage_slack_commands() -> Union[dict, str, None]: 85 | """ 86 | Uses a `SlackBot` instance to run the slash command triggered by the user. 87 | Optionally returns a Slack message dict as a reply. 88 | :return: Appropriate response for received slash command in Slack block format. 89 | """ 90 | 91 | is_valid_request, message = slack_bot.verify( 92 | body=request.get_data(), 93 | headers=request.headers, 94 | ) 95 | if not is_valid_request: 96 | return error_message(f"⚠️ Couldn't fulfill your request: {message}") 97 | 98 | # Unlike GitHub webhooks, Slack does not send the data in `requests.json`. 99 | # Instead, the data is passed in `request.form`. 100 | response: dict[str, Any] | None = slack_bot.run(raw_json=request.form) 101 | return response 102 | 103 | 104 | @app.route("/github/auth") 105 | def initiate_auth(): 106 | return github_app.redirect_to_oauth_flow(request.args.get("state")) 107 | 108 | 109 | @app.route("/github/auth/redirect") 110 | def complete_auth(): 111 | return github_app.set_up_webhooks( 112 | code=request.args.get("code"), 113 | state=request.args.get("state"), 114 | ) 115 | -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/bot/__init__.py -------------------------------------------------------------------------------- /bot/github/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import GitHubApp 2 | -------------------------------------------------------------------------------- /bot/github/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `GitHubApp` class, to handle all GitHub-related features. 3 | 4 | Important methods— 5 | * `.verify` to verify incoming events, 6 | * `.parse` to cast event payload into a GitHubEvent, 7 | * `.redirect_to_oauth_flow` to initiate GitHub OAuth flow, 8 | * `.set_up_webhooks` to set up GitHub webhooks in a repo. 9 | """ 10 | 11 | from .authenticator import Authenticator 12 | from .parser import Parser 13 | 14 | 15 | class GitHubApp(Authenticator, Parser): 16 | """ 17 | Class providing access to all functions required by the GitHub portion of the project. 18 | 19 | Specifics are delegated to parent classes `Authenticator` and `Parser`. 20 | """ 21 | 22 | def __init__( 23 | self, 24 | *, 25 | base_url: str, 26 | client_id: str, 27 | client_secret: str, 28 | ): 29 | Authenticator.__init__(self, base_url, client_id, client_secret) 30 | Parser.__init__(self) 31 | -------------------------------------------------------------------------------- /bot/github/authenticator.py: -------------------------------------------------------------------------------- 1 | import json 2 | import secrets 3 | import urllib.parse 4 | 5 | import requests 6 | import sentry_sdk 7 | from flask import redirect 8 | 9 | from .base import GitHubBase 10 | 11 | 12 | class Authenticator(GitHubBase): 13 | 14 | def __init__( 15 | self, 16 | base_url: str, 17 | client_id: str, 18 | client_secret: str, 19 | ): 20 | GitHubBase.__init__(self) 21 | self.base_url = base_url 22 | self.app_id = client_id 23 | self.app_secret = client_secret 24 | 25 | def redirect_to_oauth_flow(self, state: str): 26 | endpoint = f"https://github.com/login/oauth/authorize" 27 | params = { 28 | "scope": 29 | "admin:repo_hook", 30 | "client_id": 31 | self.app_id, 32 | "state": 33 | state, 34 | "redirect_uri": 35 | f"https://redirect.mdgspace.org/{self.base_url}" 36 | f"/github/auth/redirect", 37 | } 38 | return redirect(endpoint + "?" + urllib.parse.urlencode(params)) 39 | 40 | def set_up_webhooks(self, code: str, state: str) -> str: 41 | repository = json.loads(state).get("repository") 42 | slack_user_id = json.loads(state).get("user_id") 43 | 44 | if (repository is None) or (slack_user_id is None): 45 | return ("GitHub Redirect failed." 46 | "Incorrect or Incomplete state parameter") 47 | 48 | try: 49 | github_oauth_token = self.exchange_code_for_token(code) 50 | self.use_token_for_webhooks(github_oauth_token, repository) 51 | github_user_name = self.use_token_for_user_name(github_oauth_token) 52 | 53 | if github_user_name is not None: 54 | self.storage.add_user(slack_user_id=slack_user_id, 55 | github_user_name=github_user_name) 56 | 57 | except AuthenticationError: 58 | return ("GitHub Authentication failed. Access to " 59 | "webhooks is needed to set up your repository") 60 | except WebhookCreationError as e: 61 | return f"Webhook Creation failed with error {e.msg}. Please retry in five seconds" 62 | else: 63 | return "Webhooks have been set up successfully!" 64 | 65 | def exchange_code_for_token(self, code: str) -> str: 66 | data = { 67 | "code": code, 68 | "client_id": self.app_id, 69 | "client_secret": self.app_secret, 70 | } 71 | 72 | response = requests.post( 73 | "https://github.com/login/oauth/access_token", 74 | data=json.dumps(data), 75 | headers={ 76 | "Content-Type": "application/json", 77 | "Accept": "application/json", 78 | }, 79 | ) 80 | 81 | if response.status_code != 200: 82 | raise AuthenticationError 83 | 84 | return response.json()["access_token"] 85 | 86 | def use_token_for_webhooks(self, token: str, repository: str): 87 | webhook_secret = secrets.token_hex(20) 88 | 89 | successful = self.storage.add_secret(repository, webhook_secret) 90 | 91 | if not successful: 92 | raise DuplicationError 93 | 94 | data = { 95 | "name": "web", 96 | "active": True, 97 | "events": ["*"], 98 | "config": { 99 | "url": f"https://{self.base_url}/github/events", 100 | "content_type": "json", 101 | "secret": webhook_secret, 102 | }, 103 | } 104 | 105 | response = requests.post( 106 | f"https://api.github.com/repos/{repository}/hooks", 107 | data=json.dumps(data), 108 | headers={ 109 | "Content-Type": "application/json", 110 | "Accept": "application/vnd.github+json", 111 | "Authorization": f"Bearer {token}", 112 | }, 113 | ) 114 | 115 | if response.status_code != 201: 116 | sentry_sdk.capture_message(f"Failed during webhook creation\n" 117 | f"Status code: {response.status_code}\n" 118 | f"Content: {response.content}") 119 | raise WebhookCreationError(response.status_code) 120 | 121 | def use_token_for_user_name(self, token: str) -> str | None: 122 | response = requests.get( 123 | f"https://api.github.com/user", 124 | headers={ 125 | "Content-Type": "application/json", 126 | "Accept": "application/vnd.github+json", 127 | "Authorization": f"Bearer {token}", 128 | }, 129 | ) 130 | if response.status_code == 200: 131 | return response.json().get("login") 132 | else: 133 | return None 134 | 135 | 136 | class AuthenticationError(Exception): 137 | pass 138 | 139 | 140 | class DuplicationError(Exception): 141 | pass 142 | 143 | 144 | class WebhookCreationError(Exception): 145 | 146 | def __init__(self, error: int): 147 | self.error = error 148 | self.msg = "Error occured" 149 | if error == 403: 150 | self.msg = "Forbidden" 151 | if error == 404: 152 | self.msg = "Resource not found" 153 | if error == 422: 154 | self.msg == "Validation failed, or the endpoint has been spammed." 155 | -------------------------------------------------------------------------------- /bot/github/base.py: -------------------------------------------------------------------------------- 1 | from bot.storage import GitHubStorage 2 | 3 | 4 | class GitHubBase: 5 | """ 6 | Class containing common attributes for `Authenticator` and `Parser` 7 | """ 8 | 9 | def __init__(self): 10 | self.storage = GitHubStorage() 11 | -------------------------------------------------------------------------------- /bot/github/parser.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `Parser` and `*EventParser` classes, to handle validating and parsing of webhook data. 3 | 4 | Exposed API is only the `Parser` class, to validate and serialize the raw event data. 5 | """ 6 | import hashlib 7 | import hmac 8 | import re 9 | from abc import ABC, abstractmethod 10 | from typing import Type 11 | 12 | import sentry_sdk 13 | from flask.wrappers import Request 14 | 15 | from ..models.github import Commit, EventType, Issue, PullRequest, Ref, Repository, User 16 | from ..models.github.event import GitHubEvent 17 | from ..models.link import Link 18 | from ..utils.json import JSON 19 | from .base import GitHubBase 20 | 21 | 22 | class Parser(GitHubBase): 23 | """ 24 | Contains methods dealing with validating and parsing incoming GitHub events. 25 | """ 26 | 27 | def __init__(self): 28 | GitHubBase.__init__(self) 29 | 30 | def parse(self, event_type, raw_json) -> GitHubEvent | None: 31 | """ 32 | Checks the data against all parsers, then returns a `GitHubEvent` using the matching parser. 33 | :param event_type: Event type header received from GitHub. 34 | :param raw_json: Event data body received from GitHub. 35 | :return: `GitHubEvent` object containing all the relevant data about the event. 36 | """ 37 | json: JSON = JSON(raw_json) 38 | event_parsers: list[Type[EventParser]] = [ 39 | BranchCreateEventParser, 40 | BranchDeleteEventParser, 41 | CommitCommentEventParser, 42 | ForkEventParser, 43 | IssueOpenEventParser, 44 | IssueCloseEventParser, 45 | IssueCommentEventParser, 46 | PullCloseEventParser, 47 | PullMergeEventParser, 48 | PullOpenEventParser, 49 | PullReadyEventParser, 50 | PushEventParser, 51 | ReleaseEventParser, 52 | ReviewEventParser, 53 | ReviewCommentEventParser, 54 | StarAddEventParser, 55 | StarRemoveEventParser, 56 | TagCreateEventParser, 57 | TagDeleteEventParser, 58 | ] 59 | for event_parser in event_parsers: 60 | if event_parser.verify_payload(event_type=event_type, json=json): 61 | return event_parser.cast_payload_to_event( 62 | event_type=event_type, 63 | json=json, 64 | ) 65 | 66 | sentry_sdk.capture_message(f"Undefined event received\n" 67 | f"Type: {event_type}\n" 68 | f"Content: {raw_json}") 69 | 70 | return None 71 | 72 | def verify(self, request: Request) -> tuple[bool, str]: 73 | """ 74 | Verifies incoming GitHub event. 75 | 76 | :param request: The entire HTTP request 77 | 78 | :return: A tuple of the form (V, E) — where V indicates the validity, and E is the reason for the verdict. 79 | """ 80 | 81 | headers = request.headers 82 | if "X-Hub-Signature-256" not in headers: 83 | return False, "Request headers are imperfect" 84 | 85 | repository = request.json["repository"]["full_name"] 86 | secret = self.storage.get_secret(repository) 87 | 88 | if secret is None: 89 | return False, "Webhook hasn't been registered correctly" 90 | 91 | expected_digest = headers["X-Hub-Signature-256"].split('=', 1)[-1] 92 | digest = hmac.new( 93 | secret.encode(), 94 | request.get_data(), 95 | hashlib.sha256, 96 | ).hexdigest() 97 | is_valid = hmac.compare_digest(expected_digest, digest) 98 | 99 | if not is_valid: 100 | return False, "Payload data is imperfect" 101 | 102 | return True, "Request is secure and valid" 103 | 104 | 105 | # Helper classes: 106 | class EventParser(ABC): 107 | """ 108 | Abstract base class for all parsers, to enforce them to implement check and cast methods. 109 | """ 110 | 111 | @staticmethod 112 | @abstractmethod 113 | def verify_payload(event_type: str, json: JSON) -> bool: 114 | """ 115 | Verifies whether the passed event data is of the parser's type. 116 | :param event_type: Event type header received from GitHub. 117 | :param json: Event data body received from GitHub. 118 | :return: Whether the event is of the parser's type. 119 | """ 120 | 121 | @staticmethod 122 | @abstractmethod 123 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 124 | """ 125 | Extracts all the important data from the passed raw data, and returns it in a `GitHubEvent`. 126 | :param event_type: Event type header received from GitHub. 127 | :param json: Event data body received from GitHub. 128 | :return: `GitHubEvent` object containing all the relevant data about the event. 129 | """ 130 | 131 | 132 | class BranchCreateEventParser(EventParser): 133 | """ 134 | Parser for branch creation events. 135 | """ 136 | 137 | @staticmethod 138 | def verify_payload(event_type: str, json: JSON) -> bool: 139 | return (event_type == "create" and json["ref_type"] == "branch" 140 | and json["pusher_type"] == "user") 141 | 142 | @staticmethod 143 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 144 | return GitHubEvent( 145 | event_type=EventType.BRANCH_CREATED, 146 | repo=Repository( 147 | name=json["repository"]["full_name"], 148 | link=json["repository"]["html_url"], 149 | ), 150 | user=User(name=json["sender"][("name", "login")]), 151 | ref=Ref(name=find_ref(json["ref"])), 152 | ) 153 | 154 | 155 | class BranchDeleteEventParser(EventParser): 156 | """ 157 | Parser for branch deletion events. 158 | """ 159 | 160 | @staticmethod 161 | def verify_payload(event_type: str, json: JSON) -> bool: 162 | return (event_type == "delete" and json["ref_type"] == "branch" 163 | and json["pusher_type"] == "user") 164 | 165 | @staticmethod 166 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 167 | return GitHubEvent( 168 | event_type=EventType.BRANCH_DELETED, 169 | repo=Repository( 170 | name=json["repository"]["full_name"], 171 | link=json["repository"]["html_url"], 172 | ), 173 | user=User(name=json["sender"][("name", "login")]), 174 | ref=Ref(name=find_ref(json["ref"])), 175 | ) 176 | 177 | 178 | class CommitCommentEventParser(EventParser): 179 | """ 180 | Parser for comments on commits. 181 | """ 182 | 183 | @staticmethod 184 | def verify_payload(event_type: str, json: JSON) -> bool: 185 | return event_type == "commit_comment" and json["action"] == "created" 186 | 187 | @staticmethod 188 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 189 | return GitHubEvent( 190 | event_type=EventType.COMMIT_COMMENT, 191 | repo=Repository( 192 | name=json["repository"]["full_name"], 193 | link=json["repository"]["html_url"], 194 | ), 195 | user=User(name=json["comment"]["user"]["login"]), 196 | comments=[convert_links(json["comment"]["body"])], 197 | commits=[ 198 | Commit( 199 | sha=json["comment"]["commit_id"][:8], 200 | link=json["repository"]["html_url"] + "/commit/" + 201 | json["comment"]["commit_id"][:8], 202 | message="", 203 | ) 204 | ], 205 | links=[Link(url=json["comment"]["html_url"])], 206 | ) 207 | 208 | 209 | class ForkEventParser(EventParser): 210 | """ 211 | Parser for repository fork events. 212 | """ 213 | 214 | @staticmethod 215 | def verify_payload(event_type: str, json: JSON) -> bool: 216 | return event_type == "fork" 217 | 218 | @staticmethod 219 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 220 | return GitHubEvent( 221 | event_type=EventType.FORK, 222 | repo=Repository( 223 | name=json["repository"]["full_name"], 224 | link=json["repository"]["html_url"], 225 | ), 226 | user=User(name=json["forkee"]["owner"]["login"]), 227 | links=[Link(url=json["forkee"]["html_url"])], 228 | ) 229 | 230 | 231 | class IssueOpenEventParser(EventParser): 232 | """ 233 | Parser for issue creation events. 234 | """ 235 | 236 | @staticmethod 237 | def verify_payload(event_type: str, json: JSON) -> bool: 238 | return (event_type == "issues") and (json["action"] == "opened") 239 | 240 | @staticmethod 241 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 242 | return GitHubEvent( 243 | event_type=EventType.ISSUE_OPENED, 244 | repo=Repository( 245 | name=json["repository"]["full_name"], 246 | link=json["repository"]["html_url"], 247 | ), 248 | user=User(name=json["issue"]["user"]["login"]), 249 | issue=Issue( 250 | number=json["issue"]["number"], 251 | title=json["issue"]["title"], 252 | link=json["issue"]["html_url"], 253 | ), 254 | ) 255 | 256 | 257 | class IssueCloseEventParser(EventParser): 258 | """ 259 | Parser for issue closing events. 260 | """ 261 | 262 | @staticmethod 263 | def verify_payload(event_type: str, json: JSON) -> bool: 264 | return (event_type == "issues") and (json["action"] == "closed") 265 | 266 | @staticmethod 267 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 268 | return GitHubEvent( 269 | event_type=EventType.ISSUE_CLOSED, 270 | repo=Repository( 271 | name=json["repository"]["full_name"], 272 | link=json["repository"]["html_url"], 273 | ), 274 | user=User(name=json["issue"]["user"]["login"]), 275 | issue=Issue( 276 | number=json["issue"]["number"], 277 | title=json["issue"]["title"], 278 | link=json["issue"]["html_url"], 279 | ), 280 | ) 281 | 282 | 283 | class IssueCommentEventParser(EventParser): 284 | """ 285 | Parser for comments on issues. 286 | """ 287 | 288 | @staticmethod 289 | def verify_payload(event_type: str, json: JSON) -> bool: 290 | return event_type == "issue_comment" and json["action"] == "created" 291 | 292 | @staticmethod 293 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 294 | return GitHubEvent( 295 | event_type=EventType.ISSUE_COMMENT, 296 | repo=Repository( 297 | name=json["repository"]["full_name"], 298 | link=json["repository"]["html_url"], 299 | ), 300 | user=User(name=json["sender"]["login"]), 301 | issue=Issue( 302 | number=json["issue"]["number"], 303 | title=json["issue"]["title"], 304 | link=json["issue"]["html_url"], 305 | ), 306 | comments=[convert_links(json["comment"]["body"])], 307 | links=[Link(url=json["comment"]["html_url"])], 308 | ) 309 | 310 | 311 | class PingEventParser(EventParser): 312 | """ 313 | Parser for GitHub's testing ping events. 314 | """ 315 | 316 | @staticmethod 317 | def verify_payload(event_type: str, json: JSON) -> bool: 318 | return event_type == "ping" 319 | 320 | @staticmethod 321 | def cast_payload_to_event(event_type: str, json: JSON): 322 | print("Ping event received!") 323 | 324 | 325 | class PullCloseEventParser(EventParser): 326 | """ 327 | Parser for PR closing events. 328 | """ 329 | 330 | @staticmethod 331 | def verify_payload(event_type: str, json: JSON) -> bool: 332 | return ((event_type == "pull_request") and (json["action"] == "closed") 333 | and (not json["pull_request"]["merged"])) 334 | 335 | @staticmethod 336 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 337 | return GitHubEvent( 338 | event_type=EventType.PULL_CLOSED, 339 | repo=Repository( 340 | name=json["repository"]["full_name"], 341 | link=json["repository"]["html_url"], 342 | ), 343 | user=User(name=json["pull_request"]["user"]["login"]), 344 | pull_request=PullRequest( 345 | number=json["pull_request"]["number"], 346 | title=json["pull_request"]["title"], 347 | link=json["pull_request"]["html_url"], 348 | ), 349 | ) 350 | 351 | 352 | class PullMergeEventParser(EventParser): 353 | """ 354 | Parser for PR merging events. 355 | """ 356 | 357 | @staticmethod 358 | def verify_payload(event_type: str, json: JSON) -> bool: 359 | return ((event_type == "pull_request") and (json["action"] == "closed") 360 | and (json["pull_request"]["merged"])) 361 | 362 | @staticmethod 363 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 364 | return GitHubEvent( 365 | event_type=EventType.PULL_MERGED, 366 | repo=Repository( 367 | name=json["repository"]["full_name"], 368 | link=json["repository"]["html_url"], 369 | ), 370 | user=User(name=json["pull_request"]["user"]["login"]), 371 | pull_request=PullRequest( 372 | number=json["pull_request"]["number"], 373 | title=json["pull_request"]["title"], 374 | link=json["pull_request"]["html_url"], 375 | ), 376 | ) 377 | 378 | 379 | class PullOpenEventParser(EventParser): 380 | """ 381 | Parser for PR creation events. 382 | """ 383 | 384 | @staticmethod 385 | def verify_payload(event_type: str, json: JSON) -> bool: 386 | return (event_type == "pull_request") and (json["action"] == "opened") 387 | 388 | @staticmethod 389 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 390 | return GitHubEvent( 391 | event_type=EventType.PULL_OPENED, 392 | repo=Repository( 393 | name=json["repository"]["full_name"], 394 | link=json["repository"]["html_url"], 395 | ), 396 | user=User(name=json["pull_request"]["user"]["login"]), 397 | pull_request=PullRequest( 398 | number=json["pull_request"]["number"], 399 | title=json["pull_request"]["title"], 400 | link=json["pull_request"]["html_url"], 401 | ), 402 | ) 403 | 404 | 405 | class PullReadyEventParser(EventParser): 406 | """ 407 | Parser for PR review request events. 408 | """ 409 | 410 | @staticmethod 411 | def verify_payload(event_type: str, json: JSON) -> bool: 412 | return (event_type == "pull_request" 413 | and json["action"] == "review_requested") 414 | 415 | @staticmethod 416 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 417 | return GitHubEvent( 418 | event_type=EventType.PULL_READY, 419 | repo=Repository( 420 | name=json["repository"]["full_name"], 421 | link=json["repository"]["html_url"], 422 | ), 423 | pull_request=PullRequest( 424 | number=json["pull_request"]["number"], 425 | title=json["pull_request"]["title"], 426 | link=json["pull_request"]["html_url"], 427 | ), 428 | reviewers=[ 429 | User(name=user["login"]) 430 | for user in json["pull_request"]["requested_reviewers"] 431 | ], 432 | ) 433 | 434 | 435 | class PushEventParser(EventParser): 436 | """ 437 | Parser for code push events. 438 | """ 439 | 440 | @staticmethod 441 | def verify_payload(event_type: str, json: JSON) -> bool: 442 | return (event_type == "push") and (len(json["commits"]) > 0) 443 | 444 | @staticmethod 445 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 446 | base_url = json["repository"]["html_url"] 447 | branch_name = find_ref(json["ref"]) 448 | 449 | # Commits 450 | commits: list[Commit] = [ 451 | Commit( 452 | message=commit["message"], 453 | sha=commit["id"][:8], 454 | link=base_url + f"/commit/{commit['id']}", 455 | ) for commit in json["commits"] 456 | ] 457 | 458 | return GitHubEvent( 459 | event_type=EventType.PUSH, 460 | repo=Repository(name=json["repository"]["full_name"], 461 | link=base_url), 462 | ref=Ref(name=branch_name), 463 | user=User(name=json[("pusher", "sender")][("name", "login")]), 464 | commits=commits, 465 | ) 466 | 467 | 468 | class ReleaseEventParser(EventParser): 469 | """ 470 | Parser for release creation events. 471 | """ 472 | 473 | @staticmethod 474 | def verify_payload(event_type: str, json: JSON) -> bool: 475 | return (event_type == "release") and (json["action"] == "released") 476 | 477 | @staticmethod 478 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 479 | return GitHubEvent( 480 | event_type=EventType.RELEASE, 481 | repo=Repository( 482 | name=json["repository"]["full_name"], 483 | link=json["repository"]["html_url"], 484 | ), 485 | status="created" if json["action"] == "released" else "", 486 | ref=Ref( 487 | name=json["release"]["tag_name"], 488 | ref_type="tag", 489 | ), 490 | user=User(name=json["sender"]["login"]), 491 | ) 492 | 493 | 494 | class ReviewEventParser(EventParser): 495 | """ 496 | Parser for PR review events. 497 | """ 498 | 499 | @staticmethod 500 | def verify_payload(event_type: str, json: JSON) -> bool: 501 | return (event_type == "pull_request_review" 502 | and json["action"] == "submitted" 503 | and json["review"]["state"].lower() 504 | in ["approved", "changes_requested"]) 505 | 506 | @staticmethod 507 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 508 | return GitHubEvent( 509 | event_type=EventType.REVIEW, 510 | repo=Repository( 511 | name=json["repository"]["full_name"], 512 | link=json["repository"]["html_url"], 513 | ), 514 | pull_request=PullRequest( 515 | number=json["pull_request"]["number"], 516 | title=json["pull_request"]["title"], 517 | link=json["pull_request"]["html_url"], 518 | ), 519 | status=json["review"]["state"].lower(), 520 | reviewers=[User(name=json["sender"]["login"])], 521 | ) 522 | 523 | 524 | class ReviewCommentEventParser(EventParser): 525 | """ 526 | Parser for comments added to PR review. 527 | """ 528 | 529 | @staticmethod 530 | def verify_payload(event_type: str, json: JSON) -> bool: 531 | return (event_type == "pull_request_review_comment" 532 | and json["action"] == "created") 533 | 534 | @staticmethod 535 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 536 | return GitHubEvent( 537 | event_type=EventType.REVIEW_COMMENT, 538 | repo=Repository( 539 | name=json["repository"]["full_name"], 540 | link=json["repository"]["html_url"], 541 | ), 542 | user=User(name=json["sender"]["login"]), 543 | pull_request=PullRequest( 544 | number=json["pull_request"]["number"], 545 | title=json["pull_request"]["title"], 546 | link=json["pull_request"]["html_url"], 547 | ), 548 | comments=[convert_links(json["comment"]["body"])], 549 | links=[Link(url=json["comment"]["html_url"])], 550 | ) 551 | 552 | 553 | class StarAddEventParser(EventParser): 554 | """ 555 | Parser for repository starring events. 556 | """ 557 | 558 | @staticmethod 559 | def verify_payload(event_type: str, json: JSON) -> bool: 560 | return (event_type == "star") and (json["action"] == "created") 561 | 562 | @staticmethod 563 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 564 | return GitHubEvent( 565 | event_type=EventType.STAR_ADDED, 566 | repo=Repository( 567 | name=json["repository"]["full_name"], 568 | link=json["repository"]["html_url"], 569 | ), 570 | user=User(name=json["sender"]["login"]), 571 | ) 572 | 573 | 574 | class StarRemoveEventParser(EventParser): 575 | """ 576 | Parser for repository unstarring events. 577 | """ 578 | 579 | @staticmethod 580 | def verify_payload(event_type: str, json: JSON) -> bool: 581 | return (event_type == "star") and (json["action"] == "deleted") 582 | 583 | @staticmethod 584 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 585 | return GitHubEvent( 586 | event_type=EventType.STAR_REMOVED, 587 | repo=Repository( 588 | name=json["repository"]["full_name"], 589 | link=json["repository"]["html_url"], 590 | ), 591 | user=User(name=json["sender"]["login"]), 592 | ) 593 | 594 | 595 | class TagCreateEventParser(EventParser): 596 | """ 597 | Parser for tag creation events. 598 | """ 599 | 600 | @staticmethod 601 | def verify_payload(event_type: str, json: JSON) -> bool: 602 | return (event_type == "create" and json["ref_type"] == "tag" 603 | and json["pusher_type"] == "user") 604 | 605 | @staticmethod 606 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 607 | return GitHubEvent( 608 | event_type=EventType.TAG_CREATED, 609 | repo=Repository( 610 | name=json["repository"]["full_name"], 611 | link=json["repository"]["html_url"], 612 | ), 613 | user=User(name=json["sender"][("name", "login")]), 614 | ref=Ref(name=find_ref(json["ref"]), ref_type="tag"), 615 | ) 616 | 617 | 618 | class TagDeleteEventParser(EventParser): 619 | """ 620 | Parser for tag deletion events. 621 | """ 622 | 623 | @staticmethod 624 | def verify_payload(event_type: str, json: JSON) -> bool: 625 | return (event_type == "delete" and json["ref_type"] == "tag" 626 | and json["pusher_type"] == "user") 627 | 628 | @staticmethod 629 | def cast_payload_to_event(event_type: str, json: JSON) -> GitHubEvent: 630 | return GitHubEvent( 631 | event_type=EventType.TAG_DELETED, 632 | repo=Repository( 633 | name=json["repository"]["full_name"], 634 | link=json["repository"]["html_url"], 635 | ), 636 | user=User(name=json["sender"][("name", "login")]), 637 | ref=Ref( 638 | name=find_ref(json["ref"]), 639 | ref_type="tag", 640 | ), 641 | ) 642 | 643 | 644 | # Helper functions: 645 | def find_ref(x: str) -> str: 646 | """ 647 | Helper function to extract branch name 648 | :param x: Full version of ref id. 649 | :return: Extracted ref name. 650 | """ 651 | return x[x.find("/", x.find("/") + 1) + 1:] 652 | 653 | 654 | def convert_links(x: str) -> str: 655 | """ 656 | Helper function to format links from GitHub format to Slack format 657 | :param x: Raw GitHub text. 658 | :return: Formatted text. 659 | """ 660 | reg: str = r'\[([a-zA-Z0-9!@#$%^&*,./?\'";:_=~` ]+)\]\(([(http(s)?):\/\/(www\.)?a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b[-a-zA-Z0-9@:%_\+.~#?&//=]*)\)' 661 | gh_links: list[tuple[str, str]] = re.findall(reg, x) 662 | for (txt, link) in gh_links: 663 | old: str = f"[{txt}]({link})" 664 | txt = str(txt).strip() 665 | link = str(link).strip() 666 | new: str = f"<{link}|{txt}>" 667 | x = x.replace(old, new) 668 | return x 669 | -------------------------------------------------------------------------------- /bot/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/bot/models/__init__.py -------------------------------------------------------------------------------- /bot/models/github/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of models related to the GitHub portion of the project. 3 | """ 4 | 5 | # Import all trivial models 6 | from .commit import Commit 7 | from .event_type import EventType, convert_keywords_to_events 8 | from .issue import Issue 9 | from .pull_request import PullRequest 10 | from .ref import Ref 11 | from .repository import Repository 12 | from .user import User 13 | -------------------------------------------------------------------------------- /bot/models/github/commit.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model for a Git commit. 3 | """ 4 | 5 | 6 | class Commit: 7 | """ 8 | Model for a Git commit. 9 | 10 | :param message: The commit message. 11 | :param sha: The commit's SHA. 12 | :param link: The commit's link on GitHub. 13 | """ 14 | 15 | def __init__( 16 | self, 17 | message: str, 18 | sha: str, 19 | link: str, 20 | ): 21 | self.message = message 22 | self.sha = sha 23 | self.link = link 24 | 25 | def __str__(self) -> str: 26 | return f"<{self.message}|{self.link}>" 27 | -------------------------------------------------------------------------------- /bot/models/github/event.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model class that can store all relevant info about all events that the project handles. 3 | """ 4 | from typing import Optional 5 | 6 | from ..link import Link 7 | from . import Commit, EventType, Issue, PullRequest, Ref, Repository, User 8 | 9 | 10 | class GitHubEvent: 11 | """ 12 | Model class that can store all relevant info about all events that the project handles. 13 | 14 | :param event_type: Enum-ized type of the event in question. 15 | :param repo: Repository where the event originated. 16 | :keyword user: GitHub user who triggered the event. 17 | :keyword ref: Branch or tag ref related to the event. 18 | 19 | :keyword number: Number of the PR/Issue related to the event. 20 | :keyword title: Title of the PR/Issue related to the event. 21 | 22 | :keyword status: Status of the review where the event originated. 23 | :keyword commits: List of commits send with the event. 24 | :keyword comments: List of comments related to the event. 25 | :keyword reviewers: List of reviewers mentioned in the event. 26 | :keyword links: List of miscellaneous links. 27 | """ 28 | 29 | type: EventType 30 | repo: Repository 31 | status: Optional[str] 32 | issue: Optional[Issue] 33 | pull_request: Optional[PullRequest] 34 | ref: Optional[Ref] 35 | user: Optional[User] 36 | comments: Optional[list[str]] 37 | commits: Optional[list[Commit]] 38 | links: Optional[list[Link]] 39 | reviewers: Optional[list[User]] 40 | 41 | def __init__(self, event_type: EventType, repo: Repository, **kwargs): 42 | self.type = event_type 43 | self.repo = repo 44 | 45 | if "status" in kwargs: 46 | self.status = kwargs["status"] 47 | if "issue" in kwargs: 48 | self.issue = kwargs["issue"] 49 | if "pull_request" in kwargs: 50 | self.pull_request = kwargs["pull_request"] 51 | if "ref" in kwargs: 52 | self.ref = kwargs["ref"] 53 | if "user" in kwargs: 54 | self.user = kwargs["user"] 55 | if "comments" in kwargs: 56 | self.comments = kwargs["comments"] 57 | if "commits" in kwargs: 58 | self.commits = kwargs["commits"] 59 | if "links" in kwargs: 60 | self.links = kwargs["links"] 61 | if "reviewers" in kwargs: 62 | self.reviewers = kwargs["reviewers"] 63 | 64 | def __str__(self): 65 | string = "" 66 | for var, value in vars(self).items(): 67 | string += var + "=" 68 | if isinstance(value, (list, tuple, set)): 69 | string += str([str(v) for v in value]) 70 | else: 71 | string += str(value) 72 | string += ", " 73 | return "(" + string[:-2] + ")" 74 | -------------------------------------------------------------------------------- /bot/models/github/event_type.py: -------------------------------------------------------------------------------- 1 | """ 2 | Enum for easy access to all types of GitHub events handled by the project. 3 | """ 4 | 5 | from enum import Enum 6 | 7 | 8 | class EventType(Enum): 9 | """ 10 | Enum for easy access to all types of GitHub events handled by the project. 11 | """ 12 | 13 | # Ref 14 | BRANCH_CREATED = ("bc", "A Branch was created") 15 | BRANCH_DELETED = ("bd", "A Branch was deleted") 16 | TAG_CREATED = ("tc", "A Tag was created") 17 | TAG_DELETED = ("td", "A Tag was deleted") 18 | 19 | # PR/Issue 20 | PULL_CLOSED = ("prc", "A Pull Request was closed") 21 | PULL_MERGED = ("prm", "A Pull Request was merged") 22 | PULL_OPENED = ("pro", "A Pull Request was opened") 23 | PULL_READY = ("prr", "A Pull Request is ready") 24 | ISSUE_OPENED = ("iso", "An Issue was opened") 25 | ISSUE_CLOSED = ("isc", "An Issue was closed") 26 | 27 | # Review 28 | REVIEW = ("rv", "A Review was given on a Pull Request") 29 | REVIEW_COMMENT = ("rc", "A Comment was added to a Review") 30 | 31 | # Discussion 32 | COMMIT_COMMENT = ("cc", "A Comment was made on a Commit") 33 | ISSUE_COMMENT = ("ic", "A Comment was made on an Issue") 34 | 35 | # Misc. 36 | FORK = ("fk", "Repository was forked by a user") 37 | PUSH = ("p", "One or more Commits were pushed") 38 | RELEASE = ("rl", "A new release was published") 39 | STAR_ADDED = ("sa", "A star was added to repository") 40 | STAR_REMOVED = ("sr", "A star was removed from repository") 41 | 42 | def __init__(self, keyword, docs): 43 | self.keyword = keyword 44 | self.docs = docs 45 | 46 | 47 | def convert_keywords_to_events(keywords: list[str]) -> set[EventType]: 48 | """ 49 | Returns a set of `EventType` members corresponding to the passed keywords. 50 | If no `EventType` is matched, returns an empty set. 51 | :param keywords: List of short strings representing the events. 52 | :return: Set of `EventType` members corresponding to the keywords. 53 | """ 54 | if len(keywords) == 0 or "default" in keywords: 55 | return { 56 | EventType.BRANCH_CREATED, 57 | EventType.TAG_CREATED, 58 | EventType.PULL_OPENED, 59 | EventType.ISSUE_OPENED, 60 | EventType.REVIEW, 61 | EventType.COMMIT_COMMENT, 62 | EventType.ISSUE_COMMENT, 63 | EventType.PUSH, 64 | EventType.STAR_ADDED, 65 | } 66 | if "all" in keywords or "*" in keywords: 67 | return set(EventType) 68 | return { 69 | event_type 70 | for event_type in EventType for keyword in keywords 71 | if event_type.keyword == keyword 72 | } 73 | -------------------------------------------------------------------------------- /bot/models/github/issue.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model for a GitHub issue. 3 | """ 4 | 5 | 6 | class Issue: 7 | """ 8 | Model for a GitHub issue. 9 | 10 | :param title: Title of the issue. 11 | :param number: Issue number. 12 | :param link: Link to the issue. 13 | """ 14 | 15 | def __init__(self, title: str, number: int, link: str): 16 | self.title = title 17 | self.number = number 18 | self.link = link 19 | 20 | def __str__(self): 21 | return f"<{self.link}|#{self.number} {self.title}>" 22 | -------------------------------------------------------------------------------- /bot/models/github/pull_request.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model for a GitHub PR. 3 | """ 4 | 5 | 6 | class PullRequest: 7 | """ 8 | Model for a GitHub PR. 9 | 10 | :param title: Title of the PR. 11 | :param number: PR number. 12 | :param link: Link to the PR. 13 | """ 14 | 15 | def __init__(self, title: str, number: int, link: str): 16 | self.title = title 17 | self.number = number 18 | self.link = link 19 | 20 | def __str__(self): 21 | return f"<{self.link}|#{self.number} {self.title}>" 22 | -------------------------------------------------------------------------------- /bot/models/github/ref.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model for a Git ref (branch/tag). 3 | """ 4 | 5 | from typing import Literal 6 | 7 | 8 | class Ref: 9 | """ 10 | Model for a Git ref (branch/tag). 11 | 12 | :param name: Name of the ref. 13 | :param ref_type: "branch" or "tag". 14 | """ 15 | 16 | def __init__( 17 | self, 18 | name: str, 19 | ref_type: Literal["branch", "tag"] = "branch", 20 | ): 21 | self.name = name 22 | self.type = ref_type 23 | 24 | def __str__(self): 25 | return self.name 26 | -------------------------------------------------------------------------------- /bot/models/github/repository.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model for a GitHub repository. 3 | """ 4 | 5 | 6 | class Repository: 7 | """ 8 | Model for a GitHub repository. 9 | 10 | :param name: Name of the repo. 11 | :param link: Link to the repo on GitHub. 12 | """ 13 | 14 | def __init__(self, name: str, link: str): 15 | self.name = name 16 | self.link = link 17 | 18 | def __str__(self): 19 | return f"<{self.link}|{self.name}>" 20 | -------------------------------------------------------------------------------- /bot/models/github/user.py: -------------------------------------------------------------------------------- 1 | """ 2 | Model for a GitHub user. 3 | """ 4 | 5 | 6 | class User: 7 | """ 8 | Model for a GitHub user. 9 | 10 | :param name: Username/id of the user. 11 | :keyword link: Link to the user's GitHub profile. 12 | """ 13 | 14 | def __init__(self, name: str, **kwargs): 15 | self.name = name 16 | self.link = kwargs.get("link", f"https://github.com/{name}") 17 | 18 | def __str__(self): 19 | return f"<{self.link}|{self.name}>" 20 | -------------------------------------------------------------------------------- /bot/models/link.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `Link` model. 3 | 4 | This was separated from "slack.py" To prevent circular-import error. 5 | """ 6 | 7 | 8 | class Link: 9 | """ 10 | Holds a text string and a URL. 11 | Has an overridden `__str__` method to make posting links on Slack easier. 12 | 13 | :param url: URL that the link should lead to. 14 | :param text: Text that should be displayed instead of the link. 15 | """ 16 | 17 | def __init__(self, url: str | None = None, text: str | None = None): 18 | self.url = url 19 | self.text = text 20 | 21 | def __str__(self) -> str: 22 | """ 23 | Overridden object method for pretty-printing links. 24 | :return: String formatted like a proper Slack link. 25 | """ 26 | return f"<{self.url}|{self.text}>" 27 | -------------------------------------------------------------------------------- /bot/models/slack.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of models related to the Slack portion of the project. 3 | """ 4 | 5 | from .github import EventType 6 | 7 | 8 | class Channel: 9 | """ 10 | Model for a Slack channel with event subscriptions. 11 | 12 | :param name: The channel name, including the "#". 13 | :param events: `set` of events the channel has subscribed to. 14 | """ 15 | 16 | def __init__(self, name: str, events: set[EventType]): 17 | self.name = name 18 | self.events = events 19 | 20 | def is_subscribed_to(self, event: EventType) -> bool: 21 | """ 22 | Wrapper for `__contains__` to make the use and result more evident. 23 | :param event: EventType to be checked. 24 | :return: Whether the channel is subscribed to the passed event or not. 25 | """ 26 | return event in self.events 27 | 28 | def __str__(self) -> str: 29 | return self.name 30 | -------------------------------------------------------------------------------- /bot/slack/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import SlackBot 2 | -------------------------------------------------------------------------------- /bot/slack/base.py: -------------------------------------------------------------------------------- 1 | from slack.web.client import WebClient 2 | 3 | from bot.storage import SubscriptionStorage 4 | 5 | 6 | class SlackBotBase: 7 | """ 8 | Class containing common attributes for `Messenger` and `Runner` 9 | """ 10 | 11 | def __init__(self, token: str): 12 | self.storage = SubscriptionStorage() 13 | self.client: WebClient = WebClient(token) 14 | -------------------------------------------------------------------------------- /bot/slack/bot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `SlackBot` class, to handle all Slack-related features. 3 | 4 | Important methods— 5 | * `.inform` to notify channels about events, 6 | * `.run` to execute slash commands. 7 | """ 8 | 9 | from ..utils.log import Logger 10 | from .messenger import Messenger 11 | from .runner import Runner 12 | 13 | 14 | class SlackBot(Messenger, Runner): 15 | """ 16 | Class providing access to all functions required by the Slack portion of the project. 17 | 18 | Specifics are delegated to parent classes `Messenger` and `Runner`. 19 | """ 20 | 21 | def __init__( 22 | self, 23 | *, 24 | token: str, 25 | logger: Logger, 26 | base_url: str, 27 | secret: str, 28 | bot_id: str, 29 | ): 30 | Messenger.__init__(self, token) 31 | Runner.__init__(self, logger, base_url, secret, token, bot_id) 32 | -------------------------------------------------------------------------------- /bot/slack/messenger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `Messenger` class, which sends Slack messages according to GitHub events. 3 | """ 4 | 5 | from slack.web.client import WebClient 6 | 7 | from ..models.github import EventType 8 | from ..models.github.event import GitHubEvent 9 | from .base import SlackBotBase 10 | 11 | 12 | class Messenger(SlackBotBase): 13 | """ 14 | Sends Slack messages according to received GitHub events. 15 | """ 16 | 17 | def __init__(self, token): 18 | SlackBotBase.__init__(self, token) 19 | 20 | def inform(self, event: GitHubEvent): 21 | """ 22 | Notify the subscribed channels about the passed event. 23 | :param event: `GitHubEvent` containing all relevant data about the event. 24 | """ 25 | message, details = Messenger.compose_message(event) 26 | correct_channels: list[str] = self.calculate_channels( 27 | repository=event.repo.name, 28 | event_type=event.type, 29 | ) 30 | for channel in correct_channels: 31 | self.send_message(channel, message, details) 32 | 33 | def calculate_channels( 34 | self, 35 | repository: str, 36 | event_type: EventType, 37 | ) -> list[str]: 38 | """ 39 | Determines the Slack channels that need to be notified about the passed event. 40 | 41 | :param repository: Name of the repository that the event was triggered in. 42 | :param event_type: Enum-ized type of event. 43 | 44 | :return: List of names of channels that are subscribed to the repo+event_type. 45 | """ 46 | 47 | correct_channels: list[str] = [ 48 | subscription.channel 49 | for subscription in self.storage.get_subscriptions( 50 | repository=repository) if event_type in subscription.events 51 | ] 52 | return correct_channels 53 | 54 | @staticmethod 55 | def compose_message(event: GitHubEvent) -> tuple[str, str | None]: 56 | """ 57 | Create message and details strings according to the type of event triggered. 58 | :param event: `GitHubEvent` containing all relevant data about the event. 59 | :return: `tuple` containing the main message and optionally, extra details. 60 | """ 61 | message: str = "" 62 | details: str | None = None 63 | 64 | if event.type == EventType.BRANCH_CREATED: 65 | message = f"Branch created by {event.user}: `{event.ref}`" 66 | elif event.type == EventType.BRANCH_DELETED: 67 | message = f"Branch deleted by {event.user}: `{event.ref}`" 68 | elif event.type == EventType.COMMIT_COMMENT: 69 | message = f"<{event.links[0].url}|Comment on `{event.commits[0].sha}`> by {event.user}\n>{event.comments[0]}" 70 | elif event.type == EventType.FORK: 71 | message = f"<{event.links[0].url}|Repository forked> by {event.user}" 72 | elif event.type == EventType.ISSUE_OPENED: 73 | message = f"Issue opened by {event.user}:\n>{event.issue}" 74 | elif event.type == EventType.ISSUE_CLOSED: 75 | message = f"Issue closed by {event.user}:\n>{event.issue}" 76 | elif event.type == EventType.ISSUE_COMMENT: 77 | type_of_discussion = "Issue" if "issue" in event.issue.link else "PR" 78 | message = f"<{event.links[0].url}|Comment on {type_of_discussion} #{event.issue.number}> by {event.user}\n>{event.comments[0]}" 79 | elif event.type == EventType.PULL_CLOSED: 80 | message = f"PR closed by {event.user}:\n>{event.pull_request}" 81 | elif event.type == EventType.PULL_MERGED: 82 | message = f"PR merged by {event.user}:\n>{event.pull_request}" 83 | elif event.type == EventType.PULL_OPENED: 84 | message = f"PR opened by {event.user}:\n>{event.pull_request}" 85 | elif event.type == EventType.PULL_READY: 86 | message = ( 87 | f"Review requested on {event.pull_request}\n" 88 | f">Reviewers: {', '.join(str(reviewer) for reviewer in event.reviewers)}" 89 | ) 90 | elif event.type == EventType.PUSH: 91 | message = f"{event.user} pushed to `{event.ref}`, " 92 | if len(event.commits) == 1: 93 | message += "1 new commit." 94 | else: 95 | message += f"{len(event.commits)} new commits." 96 | details = "\n".join(f"• {commit.message}" 97 | for commit in event.commits) 98 | elif event.type == EventType.RELEASE: 99 | message = f"Release {event.status} by {event.user}: `{event.ref}`" 100 | elif event.type == EventType.REVIEW: 101 | message = ( 102 | f"Review on <{event.pull_request.link}|#{event.pull_request.number}> " 103 | f"by {event.reviewers[0]}:\n>Status: " 104 | f"{'Approved' if event.status == 'approved' else 'Changed requested'}" 105 | ) 106 | elif event.type == EventType.REVIEW_COMMENT: 107 | message = f"<{event.links[0].url}|Comment on PR #{event.pull_request.number}> by {event.user}\n>{event.comments[0]}" 108 | elif event.type == EventType.STAR_ADDED: 109 | message = f"`{event.repo.name}` received a star from `{event.user}`." 110 | elif event.type == EventType.STAR_REMOVED: 111 | message = f"`{event.repo.name}` lost a star from `{event.user}`." 112 | elif event.type == EventType.TAG_CREATED: 113 | message = f"Tag created by {event.user}: `{event.ref}`" 114 | elif event.type == EventType.TAG_DELETED: 115 | message = f"Tag deleted by {event.user}: `{event.ref}`" 116 | 117 | return message, details 118 | 119 | def send_message(self, channel: str, message: str, details: str | None): 120 | """ 121 | Sends the passed message to the passed channel. 122 | Also, optionally posts `details` in a thread under the main message. 123 | :param channel: Channel to send the message to. 124 | :param message: Main message, briefly summarizing the event. 125 | :param details: Text to be sent as a reply to the main message. Verbose stuff goes here. 126 | """ 127 | print( 128 | f"\n\nSENDING:\n{message}\n\nWITH DETAILS:\n{details}\n\nTO: {channel}" 129 | ) 130 | 131 | # Strip the team id prefix 132 | channel = channel[channel.index('#') + 1:] 133 | 134 | if details is None: 135 | self.client.chat_postMessage( 136 | channel=channel, 137 | blocks=[{ 138 | "type": "section", 139 | "text": { 140 | "type": "mrkdwn", 141 | "text": message, 142 | }, 143 | }], 144 | unfurl_links=False, 145 | unfurl_media=False, 146 | ) 147 | else: 148 | response = self.client.chat_postMessage( 149 | channel=channel, 150 | blocks=[{ 151 | "type": "section", 152 | "text": { 153 | "type": "mrkdwn", 154 | "text": message, 155 | }, 156 | }], 157 | unfurl_links=False, 158 | unfurl_media=False, 159 | ) 160 | message_id = response.data["ts"] 161 | self.client.chat_postMessage( 162 | channel=channel, 163 | blocks=[{ 164 | "type": "section", 165 | "text": { 166 | "type": "mrkdwn", 167 | "text": details, 168 | }, 169 | }], 170 | thread_ts=message_id, 171 | unfurl_links=False, 172 | unfurl_media=False, 173 | ) 174 | -------------------------------------------------------------------------------- /bot/slack/runner.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `Runner` class, which reacts to slash commands. 3 | """ 4 | import hashlib 5 | import hmac 6 | import time 7 | import urllib.parse 8 | from json import dumps as json_dumps 9 | from typing import Any 10 | 11 | from sentry_sdk import capture_message 12 | from slack.errors import SlackApiError 13 | from werkzeug.datastructures import Headers, ImmutableMultiDict 14 | 15 | from ..models.github import EventType, convert_keywords_to_events 16 | from ..utils.json import JSON 17 | from ..utils.list_manip import intersperse 18 | from ..utils.log import Logger 19 | from .base import SlackBotBase 20 | from .templates import error_message 21 | 22 | 23 | class Runner(SlackBotBase): 24 | """ 25 | Reacts to received slash commands. 26 | """ 27 | 28 | logger: Logger 29 | 30 | def __init__( 31 | self, 32 | logger: Logger, 33 | base_url: str, 34 | secret: str, 35 | token: str, 36 | bot_id: str, 37 | ): 38 | super(self.__class__, self).__init__(token) 39 | self.logger = logger 40 | self.base_url = base_url 41 | self.secret = secret.encode("utf-8") 42 | self.bot_id = bot_id 43 | 44 | def verify( 45 | self, 46 | body: bytes, 47 | headers: Headers, 48 | ) -> tuple[bool, str]: 49 | """ 50 | Checks validity of incoming Slack request. 51 | 52 | :param body: Body of the HTTP request 53 | :param headers: Headers of the HTTP request 54 | 55 | :return: A tuple of the form (V, E) — where V indicates the validity, and E is the reason for the verdict. 56 | """ 57 | 58 | if (("X-Slack-Signature" not in headers) 59 | or ("X-Slack-Request-Timestamp" not in headers)): 60 | return False, "Request headers are imperfect" 61 | 62 | timestamp = headers['X-Slack-Request-Timestamp'] 63 | 64 | if abs(time.time() - int(timestamp)) > 60 * 5: 65 | return False, "Request is too old" 66 | 67 | expected_digest = headers["X-Slack-Signature"].split('=', 1)[-1] 68 | sig_basestring = ('v0:' + timestamp + ':').encode() + body 69 | digest = hmac.new(self.secret, sig_basestring, 70 | hashlib.sha256).hexdigest() 71 | is_valid = hmac.compare_digest(expected_digest, digest) 72 | 73 | if not is_valid: 74 | return False, "Payload is imperfect" 75 | 76 | return True, "Request is secure and valid" 77 | 78 | def run(self, raw_json: ImmutableMultiDict) -> dict[str, Any] | None: 79 | """ 80 | Runs Slack slash commands sent to the bot. 81 | :param raw_json: Slash command data sent by Slack. 82 | :return: Response to the triggered command, in Slack block format. 83 | """ 84 | json: JSON = JSON.from_multi_dict(raw_json) 85 | current_channel: str = f"{json['team_id']}#{json['channel_id']}" 86 | user_id: str = json["user_id"] 87 | command: str = json["command"] 88 | args: list[str] = str(json["text"]).split() 89 | result: dict[str, Any] | None = None 90 | 91 | if ("subscribe" in command) and (len(args) > 0) and ("/" in args[0]): 92 | self.logger.log_command( 93 | f"{int(time.time() * 1000)}, " 94 | f"<{json['user_id']}|{json['user_name']}>, " 95 | f"<{json['channel_id']}|{json['channel_name']}>, " 96 | f"<{json['team_id']}|{json['team_domain']}>, " 97 | f"{command}, " 98 | f"{json['text']}") 99 | 100 | if command == "/sel-subscribe" and len(args) > 0: 101 | result = self.run_subscribe_command( 102 | current_channel=current_channel, 103 | user_id=user_id, 104 | args=args, 105 | ) 106 | elif command == "/sel-unsubscribe" and len(args) > 0: 107 | result = self.run_unsubscribe_command( 108 | current_channel=current_channel, 109 | args=args, 110 | ) 111 | elif command == "/sel-list": 112 | result = self.run_list_command( 113 | current_channel=current_channel, 114 | ephemeral=(("quiet" in args) or ("q" in args)), 115 | ) 116 | elif command == "/sel-help": 117 | result = self.run_help_command(args) 118 | 119 | return result 120 | 121 | def run_subscribe_command( 122 | self, 123 | current_channel: str, 124 | user_id: str, 125 | args: list[str], 126 | ) -> dict[str, Any]: 127 | """ 128 | Triggered by "/sel-subscribe". Adds the passed events to the channel's subscriptions. 129 | 130 | :param current_channel: Name of the current channel. 131 | :param user_id: Slack User-id of the user who entered the command. 132 | :param args: `list` of events to subscribe to. 133 | """ 134 | 135 | in_channel = self.check_bot_in_channel(current_channel=current_channel) 136 | if not in_channel: 137 | return error_message( 138 | "Unable to subscribe. To receive notifications, " 139 | "you need to invite @GitHub to this conversation " 140 | "using `/invite @Selene`") 141 | 142 | repository = args[0] 143 | if repository.find('/') == -1: 144 | return self.send_wrong_syntax_message() 145 | 146 | new_events = convert_keywords_to_events(args[1:]) 147 | 148 | subscriptions = self.storage.get_subscriptions(channel=current_channel, 149 | repository=repository) 150 | if len(subscriptions) == 1: 151 | new_events |= subscriptions[0].events 152 | 153 | self.storage.update_subscription( 154 | channel=current_channel, 155 | repository=repository, 156 | events=new_events, 157 | ) 158 | 159 | if len(subscriptions) == 0: 160 | return self.send_welcome_message(repository=repository, 161 | user_id=user_id) 162 | else: 163 | return self.run_list_command(current_channel, ephemeral=True) 164 | 165 | def send_welcome_message( 166 | self, 167 | repository: str, 168 | user_id: str, 169 | ) -> dict[str, Any]: 170 | """ 171 | Sends a message to prompt authentication for creation of webhooks. 172 | 173 | :param repository: Repository for which webhook is to be created. 174 | :param user_id: Slack User-id of the user who entered the command. 175 | """ 176 | 177 | params = {"repository": repository, "user_id": user_id} 178 | state = json_dumps(params) 179 | url = f"https://redirect.mdgspace.org/{self.base_url}" \ 180 | f"/github/auth?{urllib.parse.urlencode({'state': state})}" 181 | 182 | blocks = [{ 183 | "type": "section", 184 | "text": { 185 | "type": 186 | "mrkdwn", 187 | "text": 188 | f"To subscribe to this repository, " 189 | f"please finish connecting your GitHub " 190 | f"account <{url}|here>" 191 | } 192 | }] 193 | return { 194 | "response_type": "ephemeral", 195 | "blocks": blocks, 196 | } 197 | 198 | def run_unsubscribe_command( 199 | self, 200 | current_channel: str, 201 | args: list[str], 202 | ) -> dict[str, Any]: 203 | """ 204 | Triggered by "/sel-unsubscribe". Removes the passed events from the channel's subscriptions. 205 | 206 | :param current_channel: Name of the current channel. 207 | :param args: `list` of events to unsubscribe from. 208 | """ 209 | 210 | repository = args[0] 211 | if repository.find('/') == -1: 212 | return self.send_wrong_syntax_message() 213 | 214 | subscriptions = self.storage.get_subscriptions( 215 | channel=current_channel, 216 | repository=repository, 217 | ) 218 | 219 | if len(subscriptions) == 0: 220 | return { 221 | "response_type": 222 | "ephemeral", 223 | "blocks": [{ 224 | "type": "section", 225 | "text": { 226 | "type": 227 | "mrkdwn", 228 | "text": 229 | f"Found no subscriptions to `{repository}` in this channel" 230 | } 231 | }] 232 | } 233 | 234 | if len(subscriptions) == 1: 235 | events = subscriptions[0].events 236 | updated_events = set(events) - convert_keywords_to_events( 237 | (args[1:])) 238 | 239 | if len(updated_events) == 0: 240 | self.storage.remove_subscription(channel=current_channel, 241 | repository=repository) 242 | else: 243 | self.storage.update_subscription(channel=current_channel, 244 | repository=repository, 245 | events=updated_events) 246 | 247 | return self.run_list_command(current_channel, ephemeral=True) 248 | 249 | @staticmethod 250 | def send_wrong_syntax_message() -> dict[str, Any]: 251 | blocks = [ 252 | { 253 | "text": { 254 | "type": 255 | "mrkdwn", 256 | "text": 257 | ("*Invalid syntax for repository name!*\nPlease include owner/organisation name in repository name.\n_For example:_ `BURG3R5/github-slack-bot`" 258 | ), 259 | }, 260 | "type": "section", 261 | }, 262 | ] 263 | return { 264 | "response_type": "ephemeral", 265 | "blocks": blocks, 266 | } 267 | 268 | def run_list_command( 269 | self, 270 | current_channel: str, 271 | ephemeral: bool = False, 272 | ) -> dict[str, Any]: 273 | """ 274 | Triggered by "/sel-list". Sends a message listing the current channel's subscriptions. 275 | 276 | :param current_channel: Name of the current channel. 277 | :param ephemeral: Whether message should be ephemeral or not. 278 | 279 | :return: Message containing subscriptions for the passed channel. 280 | """ 281 | 282 | blocks: list[dict[str, Any]] = [] 283 | subscriptions = self.storage.get_subscriptions(channel=current_channel) 284 | for subscription in subscriptions: 285 | events_string = ", ".join(f"`{event.name.lower()}`" 286 | for event in subscription.events) 287 | blocks.append({ 288 | "type": "section", 289 | "text": { 290 | "type": "mrkdwn", 291 | "text": f"*{subscription.repository}*\n{events_string}", 292 | }, 293 | }) 294 | if len(blocks) != 0: 295 | blocks = intersperse(blocks, {"type": "divider"}) 296 | else: 297 | ephemeral = True 298 | blocks = [ 299 | { 300 | "text": { 301 | "type": 302 | "mrkdwn", 303 | "text": 304 | ("This channel has not yet subscribed to anything. " 305 | "You can subscribe to your favorite repositories " 306 | "using the `/sel-subscribe` command. For more info, " 307 | "use the `/sel-help` command."), 308 | }, 309 | "type": "section", 310 | }, 311 | ] 312 | return { 313 | "response_type": "ephemeral" if ephemeral else "in_channel", 314 | "blocks": blocks, 315 | } 316 | 317 | def check_bot_in_channel( 318 | self, 319 | current_channel: str, 320 | ) -> bool: 321 | subscriptions = self.storage.get_subscriptions(channel=current_channel) 322 | 323 | if len(subscriptions) != 0: 324 | return True 325 | try: 326 | response = self.client.conversations_members( 327 | channel=current_channel) 328 | return self.bot_id in response["members"] 329 | 330 | except SlackApiError as E: 331 | capture_message( 332 | f"SlackApiError {E} Failed to fetch conversation member for {current_channel}" 333 | ) 334 | 335 | @staticmethod 336 | def run_help_command(args: list[str]) -> dict[str, Any]: 337 | """ 338 | Triggered by "/sel-help". Sends an ephemeral help message as response. 339 | 340 | :param args: Arguments passed to the command. 341 | 342 | :return: Ephemeral message showcasing the bot features and keywords. 343 | """ 344 | 345 | def mini_help_response(text: str) -> dict[str, Any]: 346 | return { 347 | "response_type": 348 | "ephemeral", 349 | "blocks": [{ 350 | "type": "section", 351 | "text": { 352 | "type": "mrkdwn", 353 | "text": text 354 | } 355 | }], 356 | } 357 | 358 | if len(args) == 1: 359 | query = args[0].lower() 360 | if "unsubscribe" in query: 361 | return mini_help_response( 362 | "*/sel-unsubscribe*\n" 363 | "Unsubscribe from events in a GitHub repository\n\n" 364 | "Format: `/sel-unsubscribe / [ ...]`" 365 | ) 366 | elif "subscribe" in query: 367 | return mini_help_response( 368 | "*/sel-subscribe*\n" 369 | "Subscribe to events in a GitHub repository\n\n" 370 | "Format: `/sel-subscribe / [ ...]`" 371 | ) 372 | elif "list" in query: 373 | return mini_help_response( 374 | "*/sel-list*\n" 375 | "Lists subscriptions for the current channel\n\n" 376 | "Format: `/sel-list ['q' or 'quiet']`") 377 | else: 378 | for event in EventType: 379 | if ((query == event.keyword) 380 | or (query == event.name.lower())): 381 | return mini_help_response(f"`{event.keyword}`: " 382 | f"{event.docs}") 383 | return { 384 | "response_type": 385 | "ephemeral", 386 | "blocks": [ 387 | { 388 | "type": "section", 389 | "text": { 390 | "type": 391 | "mrkdwn", 392 | "text": 393 | ("*Commands*\n" 394 | "1. `/sel-subscribe / [ ...]`\n" 395 | "2. `/sel-unsubscribe / [ ...]`\n" 396 | "3. `/sel-list ['q' or 'quiet']`\n" 397 | "4. `/sel-help []`" 398 | ), 399 | }, 400 | }, 401 | { 402 | "type": "divider" 403 | }, 404 | { 405 | "type": "section", 406 | "text": { 407 | "type": 408 | "mrkdwn", 409 | "text": 410 | ("*Events*\n" 411 | "GitHub events are abbreviated as follows:\n" 412 | "- `default` or no arguments: Subscribe " 413 | "to the most common and important events.\n" 414 | "- `all` or `*`: Subscribe to every supported event.\n" 415 | + "".join([ 416 | f"- `{event.keyword}`: {event.docs}\n" 417 | for event in EventType 418 | ])), 419 | }, 420 | }, 421 | ], 422 | } 423 | -------------------------------------------------------------------------------- /bot/slack/templates.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | 4 | def error_message(text: str) -> dict[str, Any]: 5 | attachments = [ 6 | { 7 | "color": 8 | "#bb2124", 9 | "blocks": [ 10 | { 11 | "type": "section", 12 | "text": { 13 | "type": "mrkdwn", 14 | "text": text, 15 | }, 16 | }, 17 | ], 18 | }, 19 | ] 20 | 21 | return { 22 | "response_type": "ephemeral", 23 | "attachments": attachments, 24 | } 25 | -------------------------------------------------------------------------------- /bot/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .github import GitHubStorage 2 | from .subscriptions import SubscriptionStorage 3 | -------------------------------------------------------------------------------- /bot/storage/github.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `GitHubStorage` class, to save and fetch secrets using the peewee library. 3 | """ 4 | 5 | from typing import Optional 6 | 7 | from peewee import CharField, IntegrityError, Model, SqliteDatabase 8 | 9 | db = SqliteDatabase(None) 10 | 11 | 12 | class GitHubStorage: 13 | """ 14 | Uses the `peewee` library to save and fetch secrets from an SQL database. 15 | """ 16 | 17 | def __init__(self): 18 | global db 19 | db.init("data/github.db") 20 | db.connect() 21 | db.create_tables([GitHubSecret, User]) 22 | 23 | def add_secret( 24 | self, 25 | repository: str, 26 | secret: str, 27 | force_replace: bool = False, 28 | ) -> bool: 29 | """ 30 | Creates or updates a secret object in the database. 31 | 32 | :param repository: Unique identifier of the GitHub repository, of the form "/" 33 | :param secret: Secret used by the webhook in the given repo 34 | :param force_replace: Whether in case of duplication the old secret should be overwritten 35 | 36 | :return: `False` in case an old secret was found for the same repository, `True` otherwise. 37 | """ 38 | 39 | try: 40 | GitHubSecret\ 41 | .insert(repository=repository, secret=secret)\ 42 | .execute() 43 | return True 44 | except IntegrityError: 45 | if force_replace: 46 | GitHubSecret\ 47 | .insert(repository=repository, secret=secret)\ 48 | .on_conflict_replace()\ 49 | .execute() 50 | return False 51 | 52 | def get_secret(self, repository: str) -> Optional[str]: 53 | """ 54 | Queries the `secrets` database. 55 | 56 | :param repository: Unique identifier for the GitHub repository, of the form "/" 57 | 58 | :return: Result of query, either a string secret or `None`. 59 | """ 60 | 61 | results = GitHubSecret\ 62 | .select()\ 63 | .where(GitHubSecret.repository == repository) 64 | 65 | if len(results) == 1: 66 | return results[0].secret 67 | 68 | return None 69 | 70 | def add_user( 71 | self, 72 | slack_user_id: str, 73 | github_user_name: str, 74 | force_replace: bool = False, 75 | ): 76 | """ 77 | Creates or updates a user object in the database. 78 | 79 | :param slack_user_id: Unique identifier of the Slack User-id. 80 | :param github_user_name: Unique identifier of GitHub User-name. 81 | :param force_replace: Whether in case of duplication the old user should be overwritten. 82 | """ 83 | 84 | try: 85 | User\ 86 | .insert(slack_user_id=slack_user_id, github_user_name=github_user_name)\ 87 | .execute() 88 | except IntegrityError: 89 | if force_replace: 90 | User\ 91 | .insert(slack_user_id=slack_user_id, github_user_name=github_user_name)\ 92 | .on_conflict_replace()\ 93 | .execute() 94 | 95 | def get_slack_id(self, github_user_name) -> Optional[str]: 96 | """ 97 | Queries the `user` database for `slack_user_id` corresponding to given GitHub user-name. 98 | 99 | :param github_user_name: Unique identifier for the GitHub User-name. 100 | 101 | :return: Result of query, Slack user-id corresponding to given GitHub user-name. 102 | """ 103 | 104 | user = User\ 105 | .get_or_none(User.github_user_name == github_user_name) 106 | if user is not None: 107 | return user.slack_user_id 108 | return None 109 | 110 | def remove_user(self, slack_user_id: str = "", github_user_name: str = ""): 111 | """ 112 | Deletes the `user` entry having the given `slack_user_id` or `github_user_name` (only one is required). 113 | 114 | :param slack_user_id: Slack user-id of the entry which is to be deleted. 115 | :param github_user_name: GitHub user-name of the entry which is to be deleted. 116 | """ 117 | 118 | if slack_user_id != "": 119 | User\ 120 | .delete()\ 121 | .where(User.slack_user_id == slack_user_id)\ 122 | .execute() 123 | elif github_user_name != "": 124 | User\ 125 | .delete()\ 126 | .where(User.github_user_name == github_user_name)\ 127 | .execute() 128 | 129 | 130 | class GitHubSecret(Model): 131 | """ 132 | A peewee-friendly model that represents a repository-secret pair to be used when receiving webhooks from GitHub. 133 | 134 | :keyword repository: Unique identifier for the GitHub repository, of the form "/" 135 | :keyword secret: Secret used by the webhook in the given repo 136 | """ 137 | 138 | repository = CharField(unique=True) 139 | secret = CharField() 140 | 141 | class Meta: 142 | database = db 143 | table_name = "GitHubSecret" 144 | 145 | def __str__(self): 146 | return f"({self.repository}) — {self.secret}" 147 | 148 | 149 | class User(Model): 150 | """ 151 | A peewee-friendly model that represents a mapping between Slack user-id and GitHub user-name. 152 | 153 | :keyword slack_user_id: Unique identifier for Slack user-id. 154 | :keyword github_user_name: Unique identifier for GitHub user-name. 155 | """ 156 | 157 | slack_user_id = CharField(unique=True) 158 | github_user_name = CharField(unique=True) 159 | 160 | class Meta: 161 | database = db 162 | table_name = "User" 163 | 164 | def __str__(self): 165 | return f"{self.github_user_name} - {self.slack_user_id}" 166 | -------------------------------------------------------------------------------- /bot/storage/subscriptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `SubscriptionStorage` class, to save and fetch subscriptions using the peewee library. 3 | """ 4 | from typing import Optional 5 | 6 | from peewee import CharField, Model, SqliteDatabase 7 | from playhouse.fields import PickleField 8 | 9 | from bot.models.github import EventType, convert_keywords_to_events 10 | 11 | db = SqliteDatabase(None) 12 | 13 | 14 | class SubscriptionStorage: 15 | """ 16 | Uses the `peewee` library to save and fetch subscriptions from an SQL database. 17 | """ 18 | 19 | def __init__(self): 20 | global db 21 | db.init("data/subscriptions.db") 22 | db.connect() 23 | Subscription.create_table() 24 | Subscription.insert( 25 | channel="#selene", 26 | repository="BURG3R5/github-slack-bot", 27 | events=list(EventType), 28 | ) 29 | 30 | def remove_subscription(self, channel: str, repository: str): 31 | """ 32 | Deletes a given entry from the database. 33 | 34 | :param channel: Name of the Slack channel (including the "#") 35 | :param repository: Unique identifier of the GitHub repository, of the form "/" 36 | """ 37 | 38 | Subscription\ 39 | .delete()\ 40 | .where((Subscription.channel == channel) & (Subscription.repository == repository))\ 41 | .execute() 42 | 43 | def update_subscription( 44 | self, 45 | channel: str, 46 | repository: str, 47 | events: set[EventType], 48 | ): 49 | """ 50 | Creates or updates subscription object in the database. 51 | 52 | :param channel: Name of the Slack channel (including the "#") 53 | :param repository: Unique identifier of the GitHub repository, of the form "/" 54 | :param events: Set of events to subscribe to 55 | """ 56 | 57 | Subscription.insert( 58 | channel=channel, 59 | repository=repository, 60 | events=[e.keyword for e in events], 61 | ).on_conflict_replace().execute() 62 | 63 | def get_subscriptions( 64 | self, 65 | channel: Optional[str] = None, 66 | repository: Optional[str] = None, 67 | ) -> tuple["Subscription", ...]: 68 | """ 69 | Queries the subscriptions database. Filters are applied depending on arguments passed. 70 | 71 | :param channel: Name of the Slack channel (including the "#") 72 | :param repository: Unique identifier for the GitHub repository, of the form "/" 73 | 74 | :return: Result of query, containing `Subscription` objects with relevant fields 75 | """ 76 | 77 | if channel is None and repository is None: 78 | # No filters are provided 79 | subscriptions = Subscription.select() 80 | elif channel is None: 81 | # Only repository filter is provided 82 | subscriptions = Subscription\ 83 | .select(Subscription.channel, Subscription.events)\ 84 | .where(Subscription.repository == repository) 85 | elif repository is None: 86 | # Only channel filter is provided 87 | subscriptions = Subscription\ 88 | .select(Subscription.repository, Subscription.events)\ 89 | .where(Subscription.channel == channel) 90 | else: 91 | # Both filters are provided 92 | subscriptions = Subscription\ 93 | .select(Subscription.events)\ 94 | .where((Subscription.channel == channel) & (Subscription.repository == repository)) 95 | 96 | return tuple( 97 | Subscription( 98 | channel=subscription.channel, 99 | repository=subscription.repository, 100 | events=convert_keywords_to_events(subscription.events), 101 | ) for subscription in subscriptions) 102 | 103 | 104 | class Subscription(Model): 105 | """ 106 | A peewee-friendly model that represents one subscription. 107 | 108 | :keyword channel: Name of the Slack channel, including the "#" 109 | :keyword repository: Unique identifier for the GitHub repository, of the form "/" 110 | :keyword events: List of keyword-representations of EventType enum members 111 | """ 112 | 113 | channel = CharField() 114 | repository = CharField() 115 | # v A field that stores any Python object in a pickled string and un-pickles it automatically. 116 | events = PickleField() 117 | 118 | class Meta: 119 | database = db 120 | indexes = ((("channel", "repository"), True), ) 121 | # ^ Each (channel, repository) pair should be unique together 122 | 123 | def __str__(self): 124 | return f"({self.channel},{self.repository}) — {self.events}" 125 | -------------------------------------------------------------------------------- /bot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Collection of miscellaneous utility methods and classes for the project. 3 | """ 4 | -------------------------------------------------------------------------------- /bot/utils/json.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains the `JSON` class, which wraps a `dict` to safely extract values using multiple keys. 3 | """ 4 | 5 | from typing import Any 6 | 7 | from werkzeug.datastructures import ImmutableMultiDict 8 | 9 | 10 | class JSON: 11 | """ 12 | Wrapper for a `dict`. Safely extracts values using multiple keys. 13 | 14 | :param dictionary: A normal `dict` object. 15 | """ 16 | 17 | def __contains__(self, key) -> bool: 18 | return key in self.data 19 | 20 | def __init__(self, dictionary: dict): 21 | self.data = dictionary 22 | 23 | def __getitem__(self, keys) -> Any: 24 | 25 | def get(k): 26 | if isinstance(self.data[k], dict): 27 | return JSON(self.data[k]) 28 | return self.data[k] 29 | 30 | # Single key 31 | if isinstance(keys, str): 32 | key = keys 33 | if key in self.data: 34 | return get(key) 35 | return key.upper() 36 | # Multiple keys 37 | for key in keys: 38 | if key in self.data: 39 | return get(key) 40 | return keys[0].upper() 41 | 42 | @staticmethod 43 | def from_multi_dict(multi_dict: ImmutableMultiDict): 44 | """ 45 | Converts `werkzeug.datastructures.ImmutableMultiDict` to `JSON`. 46 | :param multi_dict: Incoming `ImmutableMultiDict`. 47 | :return: `JSON` object containing the data from the `ImmutableMultiDict`. 48 | """ 49 | return JSON({key: multi_dict[key] for key in multi_dict.keys()}) 50 | -------------------------------------------------------------------------------- /bot/utils/list_manip.py: -------------------------------------------------------------------------------- 1 | def intersperse(array: list, padding) -> list: 2 | result = [padding] * (len(array) * 2 - 1) 3 | result[0::2] = array 4 | return result 5 | -------------------------------------------------------------------------------- /bot/utils/log.py: -------------------------------------------------------------------------------- 1 | class Logger: 2 | """ 3 | Logs the latest commands to `./data/logs`. 4 | :param N: Number of latest commands to keep. 5 | """ 6 | 7 | def __init__(self, N: int): 8 | self.N = N 9 | 10 | def log_command(self, log_text: str): 11 | """ 12 | Logs the latest command to `./data/logs`. 13 | :param log_text: Information about the latest command to be saved. 14 | """ 15 | 16 | if self.N == 0: 17 | # Early exit 18 | return 19 | 20 | # Read 21 | with open('data/logs', 'a+') as file: 22 | file.seek(0) 23 | lines = file.readlines() 24 | 25 | # Update 26 | lines.append(log_text + '\n') 27 | if len(lines) > self.N: 28 | lines.pop(0) 29 | 30 | # Write 31 | with open('data/logs', 'w') as file: 32 | file.writelines(lines) 33 | -------------------------------------------------------------------------------- /bot/views.py: -------------------------------------------------------------------------------- 1 | def test_get(): 2 | """ 3 | First test endpoint. 4 | :return: Plaintext confirming server status. 5 | """ 6 | return "This server is running!" 7 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | selene: 5 | build: 6 | context: . 7 | container_name: selene 8 | ports: 9 | - "${HOST_PORT}:5000" 10 | volumes: 11 | - type: volume 12 | source: selene 13 | target: /selene/data 14 | 15 | volumes: 16 | selene: 17 | name: "selene" 18 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.2.2 2 | peewee==3.15.4 3 | python-dotenv==0.21.0 4 | requests~=2.28.1 5 | sentry-sdk[flask]==1.12.1 6 | slackclient==2.9.4 7 | Werkzeug~=2.2.2 8 | -------------------------------------------------------------------------------- /samples/.env: -------------------------------------------------------------------------------- 1 | BASE_URL=subdomain.domain.tld/path1/path2 2 | FLASK_DEBUG=1 3 | GITHUB_APP_CLIENT_ID=0123456789abcdefghij 4 | GITHUB_APP_CLIENT_SECRET=e2fbe2fbe2fbe2fbe2fbe2e2fbe2fbe2e2fbe2fb 5 | GITHUB_WEBHOOK_SECRET=3dbd2c253813c65b296b7acf67470b7e7bc116e3 6 | HOST_PORT=9999 7 | LOG_LAST_N_COMMANDS=100 8 | SENTRY_DSN=https://exampledsn.ingest.sentry.io/123 9 | SLACK_BOT_ID=B0101010101 10 | SLACK_OAUTH_TOKEN=xoxb-3943959636919-3982289583824-iKtJsairxpmjH2yrVMg99nFG 11 | -------------------------------------------------------------------------------- /samples/.env.dev: -------------------------------------------------------------------------------- 1 | SLACK_APP_ID=A0101010101 2 | BASE_URL=blah-111-22-333-444.ngrok.io 3 | FLASK_DEBUG=1 4 | GITHUB_APP_CLIENT_ID=0123456789abcdefghij 5 | GITHUB_APP_CLIENT_SECRET=e2fbe2fbe2fbe2fbe2fbe2e2fbe2fbe2e2fbe2fb 6 | GITHUB_WEBHOOK_SECRET=3dbd2c253813c65b296b7acf67470b7e7bc116e3 7 | HOST_PORT=9999 8 | LOG_LAST_N_COMMANDS=100 9 | MANIFEST_REFRESH_TOKEN=xoxe-1-randomTOKENstringPRODUCEDbySLACK 10 | SENTRY_DSN=https://dd836abb94a4295a6fe581a340c89c4@o95726.ingest.sentry.io/6227373 11 | SLACK_BOT_ID=B0101010101 12 | SLACK_OAUTH_TOKEN=xoxb-3943959636919-3982289583824-iKtJsairxpmjH2yrVMg99nFG 13 | -------------------------------------------------------------------------------- /samples/bot_manifest.yml: -------------------------------------------------------------------------------- 1 | display_information: 2 | name: Selene 3 | description: Concisely and precisely informs users of events on GitHub. 4 | background_color: "#000000" 5 | long_description: Concisely and precisely informs users of events on GitHub. Subscribe to any number of events using the `/subscribe` command. Get more usage instructions using the `/help` command. Source code at https://github.com/BURG3R5/github-slack-bot 6 | features: 7 | bot_user: 8 | display_name: Selene 9 | always_online: true 10 | slash_commands: 11 | - command: /sel-subscribe 12 | url: /slack/commands 13 | description: Subscribe to events in a GitHub repository 14 | usage_hint: repository event1 [event2, event3, ...] 15 | should_escape: false 16 | - command: /sel-unsubscribe 17 | url: /slack/commands 18 | description: Unsubscribe from events in a GitHub repository 19 | usage_hint: repository event1 [event2, event3, ...] 20 | should_escape: false 21 | - command: /sel-help 22 | url: /slack/commands 23 | description: Prints instructions and keywords. 24 | should_escape: false 25 | - command: /sel-list 26 | url: /slack/commands 27 | description: Lists subscriptions for the current channel. 28 | should_escape: false 29 | oauth_config: 30 | scopes: 31 | bot: 32 | - chat:write 33 | - chat:write.customize 34 | - commands 35 | - files:write 36 | - chat:write:public 37 | settings: 38 | org_deploy_enabled: false 39 | socket_mode_enabled: false 40 | token_rotation_enabled: false 41 | -------------------------------------------------------------------------------- /scripts/change_dev_url.py: -------------------------------------------------------------------------------- 1 | """ 2 | Steps: 3 | 1) Go to https://api.slack.com/authentication/config-tokens#creating 4 | 2) Create App config tokens 5 | 3) Paste your tokens and url in ../.env 6 | 4) Run this script 7 | 8 | """ 9 | 10 | import json 11 | import os 12 | from typing import Any 13 | 14 | import dotenv 15 | import requests 16 | 17 | 18 | def update_app_manifest() -> tuple[int, bool]: 19 | prev_manifest = get_prev_manifest() 20 | 21 | url = os.environ["BASE_URL"] 22 | if url.startswith("http://"): 23 | url = "https://" + url[7:] 24 | elif not url.startswith("https://"): 25 | url = "https://" + url 26 | if url.endswith('/'): 27 | url = url[:-1] 28 | 29 | url += "/slack/commands" 30 | 31 | for i in range(4): 32 | prev_manifest["features"]["slash_commands"][i].update({"url": url}) 33 | 34 | endpoint = "https://slack.com/api/apps.manifest.update/" 35 | response = requests.post( 36 | endpoint, 37 | params={ 38 | "app_id": os.environ["SLACK_APP_ID"], 39 | "manifest": json.dumps(prev_manifest), 40 | }, 41 | headers={ 42 | "Content-Type": "application/json", 43 | "Accept": "application/json", 44 | "Authorization": f"Bearer {access_token}", 45 | }, 46 | ) 47 | return response.status_code, response.json()["ok"] 48 | 49 | 50 | def get_prev_manifest() -> dict[str, Any]: 51 | endpoint = f"https://slack.com/api/apps.manifest.export/" 52 | response = requests.post( 53 | endpoint, 54 | params={ 55 | "app_id": os.environ["SLACK_APP_ID"], 56 | }, 57 | headers={ 58 | "Content-Type": "application/json", 59 | "Accept": "application/json", 60 | "Authorization": f"Bearer {access_token}", 61 | }, 62 | ).json() 63 | 64 | if response["ok"]: 65 | return response["manifest"] 66 | else: 67 | print(response) 68 | raise Exception() 69 | 70 | 71 | def rotate_token() -> str: 72 | endpoint = "https://slack.com/api/tooling.tokens.rotate/" 73 | response = requests.post( 74 | endpoint, 75 | params={ 76 | "refresh_token": os.environ["MANIFEST_REFRESH_TOKEN"] 77 | }, 78 | headers={ 79 | "Content-Type": "application/json", 80 | "Accept": "application/json", 81 | }, 82 | ).json() 83 | if response["ok"]: 84 | dotenv.set_key( 85 | dotenv_path=dotenv.find_dotenv(), 86 | key_to_set="MANIFEST_REFRESH_TOKEN", 87 | value_to_set=response["refresh_token"], 88 | ) 89 | return response["token"] 90 | else: 91 | print(response) 92 | raise Exception() 93 | 94 | 95 | if __name__ == "__main__": 96 | dotenv.load_dotenv(dotenv.find_dotenv()) 97 | 98 | access_token = rotate_token() 99 | status_code, is_okay = update_app_manifest() 100 | print(status_code, is_okay) 101 | -------------------------------------------------------------------------------- /scripts/setup_linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #create virtual environment 4 | python3 -m venv venv 5 | 6 | #activate the vitual env 7 | source venv/bin/activate 8 | 9 | #install the required dependencies for env 10 | pip install -r requirements.txt 11 | 12 | #install the hooks 13 | if ! hash pre-commit &> /dev/null ; then 14 | pip install pre-commit 15 | pre-commit install 16 | fi 17 | echo "Setup completed successfully" 18 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/tests/__init__.py -------------------------------------------------------------------------------- /tests/github/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/tests/github/__init__.py -------------------------------------------------------------------------------- /tests/github/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "branch_create": [ 3 | { 4 | "event_type": "create", 5 | "raw_json": { 6 | "ref": "refs/heads/branch-name", 7 | "ref_type": "branch", 8 | "pusher_type": "user", 9 | "repository": { 10 | "full_name": "example-org/example-repo", 11 | "html_url": "https://github.com/example-org/example-repo" 12 | }, 13 | "sender": { 14 | "login": "example-user" 15 | } 16 | } 17 | }, 18 | { 19 | "ref": "branch-name", 20 | "repo": "", 21 | "type": "EventType.BRANCH_CREATED", 22 | "user": "" 23 | } 24 | ], 25 | "branch_delete": [ 26 | { 27 | "event_type": "delete", 28 | "raw_json": { 29 | "ref": "branch-name", 30 | "ref_type": "branch", 31 | "pusher_type": "user", 32 | "repository": { 33 | "full_name": "example-org/example-repo", 34 | "html_url": "https://github.com/example-org/example-repo" 35 | }, 36 | "sender": { 37 | "login": "example-user" 38 | } 39 | } 40 | }, 41 | { 42 | "ref": "branch-name", 43 | "repo": "", 44 | "type": "EventType.BRANCH_DELETED", 45 | "user": "" 46 | } 47 | ], 48 | "commit_comment": [ 49 | { 50 | "event_type": "commit_comment", 51 | "raw_json": { 52 | "action": "created", 53 | "comment": { 54 | "html_url": "https://github.com/example-org/example-repo/commit/4d93b5294b201237#commitcomment-12345678", 55 | "user": { 56 | "login": "example-user" 57 | }, 58 | "commit_id": "4d93b5294b201237", 59 | "body": "comment content" 60 | }, 61 | "repository": { 62 | "full_name": "example-org/example-repo", 63 | "html_url": "https://github.com/example-org/example-repo" 64 | }, 65 | "sender": { 66 | "login": "example-user" 67 | } 68 | } 69 | }, 70 | { 71 | "comments": ["comment content"], 72 | "commits": ["<|https://github.com/example-org/example-repo/commit/4d93b529>"], 73 | "links": [""], 74 | "repo": "", 75 | "type": "EventType.COMMIT_COMMENT", 76 | "user": "" 77 | } 78 | ], 79 | "fork": [ 80 | { 81 | "event_type": "fork", 82 | "raw_json": { 83 | "forkee": { 84 | "owner": { 85 | "login": "user-who-forked" 86 | }, 87 | "html_url": "https://github.com/user-who-forked/example-repo" 88 | }, 89 | "repository": { 90 | "full_name": "example-org/example-repo", 91 | "html_url": "https://github.com/example-org/example-repo" 92 | } 93 | } 94 | }, 95 | { 96 | "links": [""], 97 | "repo": "", 98 | "type": "EventType.FORK", 99 | "user": "" 100 | } 101 | ], 102 | "push": [ 103 | { 104 | "event_type": "push", 105 | "raw_json": { 106 | "ref": "refs/heads/branch-name", 107 | "repository": { 108 | "full_name": "example-org/example-repo", 109 | "html_url": "https://github.com/example-org/example-repo" 110 | }, 111 | "pusher": { 112 | "name": "user-who-pushed" 113 | }, 114 | "sender": { 115 | "login": "user-who-pushed" 116 | }, 117 | "commits": [ 118 | { 119 | "id": "f30421319e41a3a", 120 | "message": "commit-message1" 121 | }, 122 | { 123 | "id": "5g0521417e40i37d9", 124 | "message": "commit-message2" 125 | } 126 | 127 | ] 128 | } 129 | }, 130 | { 131 | "commits": ["", ""], 132 | "ref": "branch-name", 133 | "repo": "", 134 | "type": "EventType.PUSH", 135 | "user": "" 136 | } 137 | ], 138 | "star_add": [ 139 | { 140 | "event_type": "star", 141 | "raw_json": { 142 | "action": "created", 143 | "repository": { 144 | "full_name": "example-org/example-repo", 145 | "html_url": "https://github.com/example-org/example-repo" 146 | }, 147 | "sender": { 148 | "login": "user-who-starred" 149 | } 150 | } 151 | }, 152 | { 153 | "repo": "", 154 | "type": "EventType.STAR_ADDED", 155 | "user": "" 156 | } 157 | ], 158 | "star_remove": [ 159 | { 160 | "event_type": "star", 161 | "raw_json": { 162 | "action": "deleted", 163 | "repository": { 164 | "full_name": "example-org/example-repo", 165 | "html_url": "https://github.com/example-org/example-repo" 166 | }, 167 | "sender": { 168 | "login": "user-who-unstarred" 169 | } 170 | } 171 | }, 172 | { 173 | "repo": "", 174 | "type": "EventType.STAR_REMOVED", 175 | "user": "" 176 | } 177 | ], 178 | "issue_open": [ 179 | { 180 | "event_type": "issues", 181 | "raw_json": { 182 | "action": "opened", 183 | "issue": { 184 | "html_url": "https://github.com/example-org/example-repo/issues/3", 185 | "number": 3, 186 | "title": "ExampleIssue", 187 | "user": { 188 | "login": "user-who-opened-issue" 189 | } 190 | }, 191 | "repository": { 192 | "full_name": "example-org/example-repo", 193 | "html_url": "https://github.com/example-org/example-repo" 194 | }, 195 | "sender": { 196 | "login": "user-who-opened-issue" 197 | } 198 | } 199 | }, 200 | { 201 | "repo": "", 202 | "type": "EventType.ISSUE_OPENED", 203 | "user": "", 204 | "issue": "" 205 | } 206 | ], 207 | "issue_close": [ 208 | { 209 | "event_type": "issues", 210 | "raw_json": { 211 | "action": "closed", 212 | "issue": { 213 | "html_url": "https://github.com/example-org/example-repo/issues/3", 214 | "number": 3, 215 | "title": "ExampleIssue", 216 | "user": { 217 | "login": "user-who-closed-issue" 218 | } 219 | }, 220 | "repository": { 221 | "full_name": "example-org/example-repo", 222 | "html_url": "https://github.com/example-org/example-repo" 223 | }, 224 | "sender": { 225 | "login": "user-who-closed-issue" 226 | } 227 | } 228 | }, 229 | { 230 | "repo": "", 231 | "type": "EventType.ISSUE_CLOSED", 232 | "user": "", 233 | "issue": "" 234 | } 235 | ], 236 | "issue_comment": [ 237 | { 238 | "event_type": "issue_comment", 239 | "raw_json": { 240 | "action": "created", 241 | "issue": { 242 | "html_url": "https://github.com/example-org/example-repo/issues/3", 243 | "number": 3, 244 | "title": "ExampleIssue" 245 | }, 246 | "comment": { 247 | "html_url": "https://github.com/example-org/example-repo/issues/3#issuecomment-1234567890", 248 | "body": "comment content" 249 | }, 250 | "repository": { 251 | "full_name": "example-org/example-repo", 252 | "html_url": "https://github.com/example-org/example-repo" 253 | }, 254 | "sender": { 255 | "login": "user-who-commented" 256 | } 257 | } 258 | }, 259 | { 260 | "repo": "", 261 | "type": "EventType.ISSUE_COMMENT", 262 | "user": "", 263 | "issue": "", 264 | "comments": ["comment content"], 265 | "links": [""] 266 | } 267 | ], 268 | "pull_close": [ 269 | { 270 | "event_type": "pull_request", 271 | "raw_json": { 272 | "action": "closed", 273 | "number": 3, 274 | "pull_request": { 275 | "html_url": "https://github.com/example-org/example-repo/pull/3", 276 | "number": 3, 277 | "title": "ExamplePR", 278 | "user": { 279 | "login": "user-who-closed-PR" 280 | }, 281 | "merged": false 282 | }, 283 | "repository": { 284 | "full_name": "example-org/example-repo", 285 | "html_url": "https://github.com/example-org/example-repo" 286 | } 287 | } 288 | }, 289 | { 290 | "repo": "", 291 | "type": "EventType.PULL_CLOSED", 292 | "user": "", 293 | "pull_request": "" 294 | } 295 | ], 296 | "pull_merge": [ 297 | { 298 | "event_type": "pull_request", 299 | "raw_json": { 300 | "action": "closed", 301 | "number": 3, 302 | "pull_request": { 303 | "html_url": "https://github.com/example-org/example-repo/pull/3", 304 | "number": 3, 305 | "title": "ExamplePR", 306 | "user": { 307 | "login": "user-who-merged-PR" 308 | }, 309 | "merged": true 310 | }, 311 | "repository": { 312 | "full_name": "example-org/example-repo", 313 | "html_url": "https://github.com/example-org/example-repo" 314 | } 315 | } 316 | }, 317 | { 318 | "repo": "", 319 | "type": "EventType.PULL_MERGED", 320 | "user": "", 321 | "pull_request": "" 322 | } 323 | ], 324 | "pull_open": [ 325 | { 326 | "event_type": "pull_request", 327 | "raw_json": { 328 | "action": "opened", 329 | "number": 3, 330 | "pull_request": { 331 | "html_url": "https://github.com/example-org/example-repo/pull/3", 332 | "number": 3, 333 | "title": "ExamplePR", 334 | "user": { 335 | "login": "user-who-opened-PR" 336 | } 337 | }, 338 | "repository": { 339 | "full_name": "example-org/example-repo", 340 | "html_url": "https://github.com/example-org/example-repo" 341 | } 342 | } 343 | }, 344 | { 345 | "repo": "", 346 | "type": "EventType.PULL_OPENED", 347 | "user": "", 348 | "pull_request": "" 349 | } 350 | ], 351 | "pull_ready": [ 352 | { 353 | "event_type": "pull_request", 354 | "raw_json": { 355 | "action": "review_requested", 356 | "pull_request": { 357 | "html_url": "https://github.com/example-org/example-repo/pull/3", 358 | "number": 3, 359 | "title": "ExamplePR", 360 | "requested_reviewers": [ 361 | { 362 | "login": "reviewer1" 363 | }, 364 | { 365 | "login": "reviewer2" 366 | } 367 | ] 368 | }, 369 | "repository": { 370 | "full_name": "example-org/example-repo", 371 | "html_url": "https://github.com/example-org/example-repo" 372 | } 373 | } 374 | }, 375 | { 376 | "repo": "", 377 | "type": "EventType.PULL_READY", 378 | "reviewers": ["",""], 379 | "pull_request": "" 380 | } 381 | ], 382 | "release": [ 383 | { 384 | "event_type": "release", 385 | "raw_json": { 386 | "action": "released", 387 | "release": { 388 | "tag_name": "example-tag" 389 | }, 390 | "repository": { 391 | "full_name": "example-org/example-repo", 392 | "html_url": "https://github.com/example-org/example-repo" 393 | }, 394 | "sender": { 395 | "login": "example-user" 396 | } 397 | } 398 | }, 399 | { 400 | "ref": "example-tag", 401 | "repo": "", 402 | "type": "EventType.RELEASE", 403 | "status": "created", 404 | "user": "" 405 | } 406 | ], 407 | "review": [ 408 | { 409 | "event_type": "pull_request_review", 410 | "raw_json": { 411 | "action": "submitted", 412 | "review": { 413 | "state": "changes_requested" 414 | }, 415 | "pull_request": { 416 | "html_url": "https://github.com/example-org/example-repo/pull/3", 417 | "number": 3, 418 | "title": "ExamplePR" 419 | }, 420 | "repository": { 421 | "full_name": "example-org/example-repo", 422 | "html_url": "https://github.com/example-org/example-repo" 423 | }, 424 | "sender": { 425 | "login": "reviewer" 426 | } 427 | } 428 | }, 429 | { 430 | "repo": "", 431 | "type": "EventType.REVIEW", 432 | "pull_request": "", 433 | "status": "changes_requested", 434 | "reviewers": [""] 435 | } 436 | ], 437 | "review_comment": [ 438 | { 439 | "event_type": "pull_request_review_comment", 440 | "raw_json": { 441 | "action": "created", 442 | "comment": { 443 | "url": "https://api.github.com/repos/example-org/example-repo/pulls/comments/123456789", 444 | "html_url": "https://github.com/example-org/example-repo/pull/3#discussion_r123456789", 445 | "body": "comment content" 446 | }, 447 | "pull_request": { 448 | "html_url": "https://github.com/example-org/example-repo/pull/3", 449 | "number": 3, 450 | "title": "ExamplePR" 451 | }, 452 | "repository": { 453 | "full_name": "example-org/example-repo", 454 | "html_url": "https://github.com/example-org/example-repo" 455 | }, 456 | "sender": { 457 | "login": "user-who-commented" 458 | } 459 | } 460 | }, 461 | { 462 | "repo": "", 463 | "type": "EventType.REVIEW_COMMENT", 464 | "user": "", 465 | "pull_request": "", 466 | "comments": ["comment content"], 467 | "links": [""] 468 | } 469 | ], 470 | "tag_create": [ 471 | { 472 | "event_type": "create", 473 | "raw_json": { 474 | "ref": "example-tag", 475 | "ref_type": "tag", 476 | "pusher_type": "user", 477 | "repository": { 478 | "full_name": "example-org/example-repo", 479 | "html_url": "https://github.com/example-org/example-repo" 480 | }, 481 | "sender": { 482 | "login": "user-who-created-tag" 483 | } 484 | } 485 | }, 486 | { 487 | "ref": "example-tag", 488 | "repo": "", 489 | "type": "EventType.TAG_CREATED", 490 | "user": "" 491 | } 492 | ], 493 | "tag_delete": [ 494 | { 495 | "event_type": "delete", 496 | "raw_json": { 497 | "ref": "example-tag", 498 | "ref_type": "tag", 499 | "pusher_type": "user", 500 | "repository": { 501 | "full_name": "example-org/example-repo", 502 | "html_url": "https://github.com/example-org/example-repo" 503 | }, 504 | "sender": { 505 | "login": "user-who-deleted-tag" 506 | } 507 | } 508 | }, 509 | { 510 | "ref": "example-tag", 511 | "repo": "", 512 | "type": "EventType.TAG_DELETED", 513 | "user": "" 514 | } 515 | ] 516 | } 517 | -------------------------------------------------------------------------------- /tests/github/test_parser.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from typing import Any 3 | 4 | from bot.github.parser import Parser, convert_links, find_ref 5 | 6 | from ..test_utils.deserializers import github_payload_deserializer 7 | from ..test_utils.load import load_test_data 8 | from ..test_utils.serializers import github_event_serializer 9 | 10 | 11 | class TestMetaClass(type): 12 | 13 | def __new__(mcs, name: str, bases: tuple[type, ...], 14 | attributes: dict[str, Any]): 15 | 16 | def generate_test(raw_input: dict[str, Any], 17 | expected_output: dict[str, Any]): 18 | 19 | def test_parser(self): 20 | event_type, raw_json = github_payload_deserializer(raw_input) 21 | listener = Parser() 22 | 23 | parsed_event = listener.parse(event_type, raw_json) 24 | 25 | self.assertEqual( 26 | github_event_serializer(parsed_event), 27 | expected_output, 28 | ) 29 | 30 | return test_parser 31 | 32 | data: dict[str, Any] = load_test_data('github') 33 | for method_name, (input, output) in data.items(): 34 | attributes['test_' + method_name] = generate_test(input, output) 35 | 36 | return type.__new__(mcs, name, bases, attributes) 37 | 38 | 39 | class GitHubListenerTest(unittest.TestCase, metaclass=TestMetaClass): 40 | # Parser tests are created dynamically by metaclass 41 | 42 | def test_find_ref(self): 43 | self.assertEqual("name", find_ref("refs/heads/name")) 44 | self.assertEqual("branch-name", find_ref("refs/heads/branch-name")) 45 | self.assertEqual("username/branch-name", 46 | find_ref("refs/heads/username/branch-name")) 47 | self.assertEqual("branch-name", find_ref("branch-name")) 48 | 49 | def test_convert_links(self): 50 | self.assertEqual( 51 | "Some comment text text", 52 | convert_links("Some comment text [Link text](www.xyz.com) text")) 53 | self.assertEqual( 54 | "Some comment text text", 55 | convert_links( 56 | "Some comment text [Link text](www.xyz.com/abcd) text")) 57 | self.assertEqual( 58 | "Some comment text text", 59 | convert_links( 60 | "Some comment text [Link text](www.xyz.com?q=1234) text")) 61 | self.assertEqual( 62 | "Some comment text text ", 63 | convert_links( 64 | "Some comment text [Link text](www.xyz.com) text [Link text 2nd](https://www.qwerty.com/)" 65 | )) 66 | self.assertEqual( 67 | "Some comment text [Link text ](www.xyz.com) text", 68 | convert_links( 69 | "Some comment text [Link text [Link inside link text](www.example.link.com)](www.xyz.com) text" 70 | )) 71 | 72 | 73 | if __name__ == '__main__': 74 | unittest.main() 75 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/mocks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/tests/mocks/__init__.py -------------------------------------------------------------------------------- /tests/mocks/slack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/tests/mocks/slack/__init__.py -------------------------------------------------------------------------------- /tests/mocks/slack/base.py: -------------------------------------------------------------------------------- 1 | from ..storage import MockSubscriptionStorage 2 | 3 | 4 | class MockSlackBotBase: 5 | """ 6 | Mock class containing common attributes for `TestableMessenger` and `TestableRunner` 7 | """ 8 | 9 | def __init__(self, _: str): 10 | self.storage = MockSubscriptionStorage() 11 | self.client = None 12 | -------------------------------------------------------------------------------- /tests/mocks/slack/runner.py: -------------------------------------------------------------------------------- 1 | from bot.slack.runner import Runner 2 | 3 | from .base import MockSlackBotBase 4 | 5 | TestableRunner = type( 6 | 'TestableRunner', 7 | (MockSlackBotBase, ), 8 | dict(Runner.__dict__), 9 | ) 10 | -------------------------------------------------------------------------------- /tests/mocks/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from .subscriptions import MockSubscriptionStorage 2 | -------------------------------------------------------------------------------- /tests/mocks/storage/subscriptions.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, Optional 2 | 3 | from bot.models.github import EventType 4 | 5 | 6 | class Subscription(NamedTuple): 7 | channel: str 8 | repository: str 9 | events: set[EventType] 10 | 11 | 12 | class MockSubscriptionStorage: 13 | 14 | def __init__(self, subscriptions: list[Subscription] = None): 15 | if subscriptions is None: 16 | self.subscriptions = [ 17 | Subscription( 18 | "workspace#selene", 19 | "BURG3R5/github-slack-bot", 20 | set(EventType), 21 | ) 22 | ] 23 | else: 24 | self.subscriptions = subscriptions 25 | 26 | def get_subscriptions( 27 | self, 28 | channel: Optional[str] = None, 29 | repository: Optional[str] = None, 30 | ) -> tuple[Subscription, ...]: 31 | shortlist = self.subscriptions 32 | if channel is not None: 33 | shortlist = (sub for sub in self.subscriptions 34 | if sub.channel == channel) 35 | if repository is not None: 36 | shortlist = (sub for sub in self.subscriptions 37 | if sub.repository == repository) 38 | return tuple(shortlist) 39 | -------------------------------------------------------------------------------- /tests/slack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/tests/slack/__init__.py -------------------------------------------------------------------------------- /tests/slack/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "run|calls_subscribe": [ 3 | { 4 | "channel_id": "selene", 5 | "team_id": "workspace", 6 | "user_id": "USER101", 7 | "command": "/sel-subscribe", 8 | "text": "BURG3R5/github-slack-bot *" 9 | }, 10 | {} 11 | ], 12 | "run|calls_unsubscribe": [ 13 | { 14 | "channel_name": "example-channel", 15 | "user_name": "example.user.123", 16 | "command": "/unsubscribe", 17 | "text": "github-slack-bot *" 18 | }, 19 | {} 20 | ], 21 | "run|calls_list": [ 22 | { 23 | "channel_name": "example-channel", 24 | "user_name": "example.user.123", 25 | "command": "/list" 26 | }, 27 | {} 28 | ], 29 | "run|calls_help": [ 30 | { 31 | "channel_name": "example-channel", 32 | "user_name": "example.user.123", 33 | "command": "/help" 34 | }, 35 | {} 36 | ], 37 | "run|doesnt_call": [ 38 | { 39 | "channel_name": "example-channel", 40 | "user_name": "example.user.123", 41 | "command": "/fake-command", 42 | "text": "github-slack-bot" 43 | }, 44 | { 45 | "channel_name": "example-channel", 46 | "user_name": "example.user.123", 47 | "command": "/subscribe", 48 | "text": "" 49 | }, 50 | { 51 | "channel_name": "example-channel", 52 | "user_name": "example.user.123", 53 | "command": "/unsubscribe", 54 | "text": "" 55 | }, 56 | {} 57 | ], 58 | "run_unsubscribe_command|single_event": [ 59 | {}, 60 | { 61 | "response_type": "ephemeral", 62 | "blocks": [ 63 | { 64 | "type": "section", 65 | "text": { 66 | "text": "*github-slack-bot*\n`branch_created`, `push`, `fork`, `star_added`, `release`, `star_removed`, `tag_created`, `pull_opened`, `issue_comment`, `pull_closed`, `branch_deleted`, `commit_comment`, `review_comment`, `pull_merged`, `tag_deleted`, `issue_opened`, `pull_ready`, `review`", 67 | "type": "mrkdwn" 68 | } 69 | }, 70 | { 71 | "type": "divider" 72 | } 73 | ] 74 | } 75 | ], 76 | "run_unsubscribe_command|single_events": [ 77 | {}, 78 | { 79 | "response_type": "ephemeral", 80 | "blocks": [ 81 | { 82 | "type": "section", 83 | "text": { 84 | "text": "*github-slack-bot*\n`branch_created`, `fork`, `star_added`, `release`, `star_removed`, `tag_created`, `pull_opened`, `issue_comment`, `pull_closed`, `branch_deleted`, `commit_comment`, `review_comment`, `pull_merged`, `tag_deleted`, `issue_opened`, `pull_ready`, `review`", 85 | "type": "mrkdwn" 86 | } 87 | }, 88 | { 89 | "type": "divider" 90 | } 91 | ] 92 | } 93 | ], 94 | "run_unsubscribe_command|single_noargs": [ 95 | {}, 96 | { 97 | "response_type": "ephemeral", 98 | "blocks": [ 99 | { 100 | "type": "section", 101 | "text": { 102 | "text": "*github-slack-bot*\n`fork`, `release`, `star_removed`, `pull_closed`, `branch_deleted`, `pull_merged`, `tag_deleted`, `review_comment`, `issue_closed`, `pull_ready`", 103 | "type": "mrkdwn" 104 | } 105 | }, 106 | { 107 | "type": "divider" 108 | } 109 | ] 110 | } 111 | ], 112 | "run_unsubscribe_command|single_all": [ 113 | {}, 114 | { 115 | "response_type": "ephemeral", 116 | "blocks": [ 117 | { 118 | "type": "section", 119 | "text": { 120 | "text": "This channel has not yet subscribed to anything. You can subscribe to your favorite repositories using the `/subscribe` command. For more info, use the `/help` command.", 121 | "type": "mrkdwn" 122 | } 123 | } 124 | ] 125 | } 126 | ], 127 | "run_unsubscribe_command|multiple_event": [ 128 | { 129 | "github-slack-bot": { 130 | "#selene": [ 131 | "ic", 132 | "p", 133 | "isc", 134 | "tc", 135 | "rv", 136 | "rc", 137 | "sr", 138 | "prm", 139 | "sa", 140 | "fk", 141 | "bd", 142 | "bc", 143 | "td", 144 | "prc", 145 | "rl", 146 | "iso", 147 | "prr", 148 | "cc", 149 | "pro" 150 | ], 151 | "#example-channel": [ 152 | "ic", 153 | "p", 154 | "isc", 155 | "prm", 156 | "sa", 157 | "fk", 158 | "bd", 159 | "bc", 160 | "td", 161 | "cc", 162 | "pro" 163 | ] 164 | }, 165 | "example-repo": { 166 | "#selene": [ 167 | "ic", 168 | "isc", 169 | "prm", 170 | "prc", 171 | "iso", 172 | "prr", 173 | "pro" 174 | ], 175 | "#example-channel": [ 176 | "ic", 177 | "p", 178 | "isc", 179 | "prm", 180 | "sa", 181 | "fk", 182 | "bd", 183 | "bc", 184 | "td", 185 | "cc", 186 | "pro" 187 | ] 188 | } 189 | }, 190 | { 191 | "response_type": "ephemeral", 192 | "blocks": [ 193 | { 194 | "type": "section", 195 | "text": { 196 | "text": "*github-slack-bot*\n`branch_created`, `push`, `fork`, `star_added`, `release`, `star_removed`, `tag_created`, `pull_opened`, `issue_comment`, `pull_closed`, `branch_deleted`, `commit_comment`, `review_comment`, `pull_merged`, `tag_deleted`, `issue_opened`, `pull_ready`, `review`", 197 | "type": "mrkdwn" 198 | } 199 | }, 200 | { 201 | "type": "divider" 202 | }, 203 | { 204 | "type": "section", 205 | "text": { 206 | "text": "*example-repo*\n`pull_opened`, `issue_comment`, `issue_opened`, `pull_closed`, `pull_merged`, `pull_ready`, `issue_closed`", 207 | "type": "mrkdwn" 208 | } 209 | }, 210 | { 211 | "type": "divider" 212 | } 213 | ] 214 | } 215 | ], 216 | "run_unsubscribe_command|multiple_events": [ 217 | {}, 218 | { 219 | "response_type": "ephemeral", 220 | "blocks": [ 221 | { 222 | "type": "section", 223 | "text": { 224 | "text": "*github-slack-bot*\n`branch_created`, `fork`, `star_added`, `release`, `star_removed`, `tag_created`, `pull_opened`, `issue_comment`, `pull_closed`, `branch_deleted`, `commit_comment`, `review_comment`, `pull_merged`, `tag_deleted`, `issue_opened`, `pull_ready`, `review`", 225 | "type": "mrkdwn" 226 | } 227 | }, 228 | { 229 | "type": "divider" 230 | }, 231 | { 232 | "type": "section", 233 | "text": { 234 | "text": "*example-repo*\n`pull_opened`, `issue_comment`, `issue_opened`, `pull_closed`, `pull_merged`, `pull_ready`, `issue_closed`", 235 | "type": "mrkdwn" 236 | } 237 | }, 238 | { 239 | "type": "divider" 240 | } 241 | ] 242 | } 243 | ], 244 | "run_unsubscribe_command|multiple_noargs": [ 245 | {}, 246 | { 247 | "response_type": "ephemeral", 248 | "blocks": [ 249 | { 250 | "type": "section", 251 | "text": { 252 | "text": "*github-slack-bot*\n`fork`, `release`, `star_removed`, `pull_closed`, `branch_deleted`, `pull_merged`, `tag_deleted`, `review_comment`, `issue_closed`, `pull_ready`", 253 | "type": "mrkdwn" 254 | } 255 | }, 256 | { 257 | "type": "divider" 258 | }, 259 | { 260 | "type": "section", 261 | "text": { 262 | "text": "*example-repo*\n`pull_opened`, `issue_comment`, `issue_opened`, `pull_closed`, `pull_merged`, `pull_ready`, `issue_closed`", 263 | "type": "mrkdwn" 264 | } 265 | }, 266 | { 267 | "type": "divider" 268 | } 269 | ] 270 | } 271 | ], 272 | "run_unsubscribe_command|multiple_all": [ 273 | {}, 274 | { 275 | "response_type": "ephemeral", 276 | "blocks": [ 277 | { 278 | "type": "section", 279 | "text": { 280 | "text": "*example-repo*\n`pull_opened`, `issue_comment`, `issue_opened`, `pull_closed`, `pull_merged`, `pull_ready`, `issue_closed`", 281 | "type": "mrkdwn" 282 | } 283 | }, 284 | { 285 | "type": "divider" 286 | } 287 | ] 288 | } 289 | ], 290 | "run_help_command": [ 291 | {}, 292 | { 293 | "response_type": "ephemeral", 294 | "blocks": [ 295 | { 296 | "type": "section", 297 | "text": { 298 | "type": "mrkdwn", 299 | "text": "*Commands*\n1. `/subscribe / [ ...]`\n2. `/unsubscribe / [ ...]`\n3. `/list`\n4. `/help []`" 300 | } 301 | }, 302 | { 303 | "type": "divider" 304 | }, 305 | { 306 | "type": "section", 307 | "text": { 308 | "type": "mrkdwn", 309 | "text": "*Events*\nGitHub events are abbreviated as follows:\n- `default` or no arguments: Subscribe to the most common and important events.\n- `all` or `*`: Subscribe to every supported event.\n- `bc`: A Branch was created\n- `bd`: A Branch was deleted\n- `tc`: A Tag was created\n- `td`: A Tag was deleted\n- `prc`: A Pull Request was closed\n- `prm`: A Pull Request was merged\n- `pro`: A Pull Request was opened\n- `prr`: A Pull Request is ready\n- `iso`: An Issue was opened\n- `isc`: An Issue was closed\n- `rv`: A Review was given on a Pull Request\n- `rc`: A Comment was added to a Review\n- `cc`: A Comment was made on a Commit\n- `ic`: A Comment was made on an Issue\n- `fk`: Repository was forked by a user\n- `p`: One or more Commits were pushed\n- `rl`: A new release was published\n- `sa`: A star was added to repository\n- `sr`: A star was removed from repository\n" 310 | } 311 | } 312 | ] 313 | } 314 | ], 315 | "run_list_command|empty": [ 316 | {}, 317 | { 318 | "response_type": "ephemeral", 319 | "blocks": [ 320 | { 321 | "type": "section", 322 | "text": { 323 | "type": "mrkdwn", 324 | "text": "This channel has not yet subscribed to anything. You can subscribe to your favorite repositories using the `/sel-subscribe` command. For more info, use the `/sel-help` command." 325 | } 326 | } 327 | ] 328 | } 329 | ], 330 | "run_list_command|empty_quiet": [ 331 | {}, 332 | { 333 | "response_type": "ephemeral", 334 | "blocks": [ 335 | { 336 | "type": "section", 337 | "text": { 338 | "type": "mrkdwn", 339 | "text": "This channel has not yet subscribed to anything. You can subscribe to your favorite repositories using the `/sel-subscribe` command. For more info, use the `/sel-help` command." 340 | } 341 | } 342 | ] 343 | } 344 | ], 345 | "run_list_command|default": [ 346 | {}, 347 | { 348 | "response_type": "in_channel", 349 | "blocks": [ 350 | { 351 | "type": "section", 352 | "text": { 353 | "text": "*github-slack-bot*\n`branch_created`, `push`, `fork`, `star_added`, `release`, `star_removed`, `tag_created`, `pull_opened`, `issue_comment`, `pull_closed`, `branch_deleted`, `commit_comment`, `review_comment`, `pull_merged`, `tag_deleted`, `issue_opened`, `pull_ready`, `review`, `issue_closed`", 354 | "type": "mrkdwn" 355 | } 356 | }, 357 | { 358 | "type": "divider" 359 | } 360 | ] 361 | } 362 | ], 363 | "run_list_command|missing": [ 364 | {}, 365 | { 366 | "response_type": "ephemeral", 367 | "blocks": [ 368 | { 369 | "type": "section", 370 | "text": { 371 | "type": "mrkdwn", 372 | "text": "This channel has not yet subscribed to anything. You can subscribe to your favorite repositories using the `/subscribe` command. For more info, use the `/help` command." 373 | } 374 | } 375 | ] 376 | } 377 | ], 378 | "run_list_command|multiple_channels": [ 379 | {}, 380 | { 381 | "response_type": "in_channel", 382 | "blocks": [ 383 | { 384 | "type": "section", 385 | "text": { 386 | "text": "*example-repo*\n`commit_comment`, `issue_opened`, `tag_created`, `pull_opened`, `review`, `push`, `branch_created`, `issue_comment`, `star_added`", 387 | "type": "mrkdwn" 388 | } 389 | }, 390 | { 391 | "type": "divider" 392 | } 393 | ] 394 | } 395 | ], 396 | "run_list_command|multiple_repos": [ 397 | {}, 398 | { 399 | "response_type": "in_channel", 400 | "blocks": [ 401 | { 402 | "type": "section", 403 | "text": { 404 | "text": "*github-slack-bot*\n`branch_created`, `push`, `fork`, `star_added`, `release`, `star_removed`, `tag_created`, `pull_opened`, `issue_comment`, `pull_closed`, `branch_deleted`, `commit_comment`, `review_comment`, `pull_merged`, `tag_deleted`, `issue_opened`, `pull_ready`, `review`, `issue_closed`", 405 | "type": "mrkdwn" 406 | } 407 | }, 408 | { 409 | "type": "divider" 410 | }, 411 | { 412 | "type": "section", 413 | "text": { 414 | "text": "*example-repo*\n`star_added`, `issue_comment`, `review`, `tag_created`, `commit_comment`, `branch_created`, `pull_opened`, `push`, `issue_opened`", 415 | "type": "mrkdwn" 416 | } 417 | }, 418 | { 419 | "type": "divider" 420 | } 421 | ] 422 | } 423 | ], 424 | "run_list_command|overlapping": [ 425 | {}, 426 | { 427 | "response_type": "in_channel", 428 | "blocks": [ 429 | { 430 | "type": "section", 431 | "text": { 432 | "text": "*github-slack-bot*\n`branch_created`, `push`, `fork`, `star_added`, `release`, `star_removed`, `tag_created`, `pull_opened`, `issue_comment`, `pull_closed`, `branch_deleted`, `commit_comment`, `review_comment`, `pull_merged`, `tag_deleted`, `issue_opened`, `pull_ready`, `review`, `issue_closed`", 433 | "type": "mrkdwn" 434 | } 435 | }, 436 | { 437 | "type": "divider" 438 | }, 439 | { 440 | "type": "section", 441 | "text": { 442 | "text": "*example-repo*\n`star_added`, `issue_comment`, `review`, `tag_created`, `commit_comment`, `branch_created`, `pull_opened`, `push`, `issue_opened`", 443 | "type": "mrkdwn" 444 | } 445 | }, 446 | { 447 | "type": "divider" 448 | } 449 | ] 450 | } 451 | ] 452 | } 453 | -------------------------------------------------------------------------------- /tests/slack/test_runner.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import skip 3 | from unittest.mock import patch 4 | 5 | from werkzeug.datastructures import ImmutableMultiDict 6 | 7 | from bot.models.github import convert_keywords_to_events 8 | from bot.models.slack import Channel 9 | from bot.utils.log import Logger 10 | 11 | from ..mocks.slack.runner import TestableRunner 12 | from ..mocks.storage import MockSubscriptionStorage 13 | from ..test_utils.comparators import Comparators 14 | from ..test_utils.deserializers import subscriptions_deserializer 15 | from ..test_utils.load import load_test_data 16 | 17 | 18 | class RunnerTest(unittest.TestCase): 19 | 20 | @classmethod 21 | def setUpClass(cls): 22 | # Fetch test data 23 | cls.data = load_test_data('slack') 24 | 25 | # Construct common Runner instance. 26 | cls.logger = logger = Logger(0) 27 | secret = "example_secret" 28 | base_url = "sub.example.com" 29 | token = "xoxb-slack-token-101" 30 | bot_id = "B0123456789" 31 | cls.runner = TestableRunner(logger, base_url, secret, token, bot_id) 32 | 33 | def setUp(self): 34 | # Reset subscriptions before every test 35 | self.runner.storage = MockSubscriptionStorage() 36 | 37 | def test_run_calls_subscribe(self): 38 | raw_json = ImmutableMultiDict(self.data["run|calls_subscribe"][0]) 39 | 40 | with patch.object(self.logger, "log_command") as log_command: 41 | with patch.object( 42 | self.runner, 43 | "run_subscribe_command", 44 | ) as run_subscribe_command: 45 | self.runner.run(raw_json) 46 | 47 | run_subscribe_command.assert_called_once_with( 48 | current_channel="workspace#selene", 49 | user_id="USER101", 50 | args=["BURG3R5/github-slack-bot", "*"], 51 | ) 52 | log_command.assert_called_once() 53 | 54 | @skip('This test is being skipped for the current PR') 55 | def test_run_calls_unsubscribe(self): 56 | raw_json = ImmutableMultiDict(self.data["run|calls_unsubscribe"][0]) 57 | with patch.object(self.logger, "log_command") as mock_logger: 58 | with patch.object(self.runner, 59 | "run_unsubscribe_command") as mock_function: 60 | self.runner.run(raw_json) 61 | mock_function.assert_called_once_with( 62 | current_channel="#example-channel", 63 | args=["github-slack-bot", "*"], 64 | ) 65 | mock_logger.assert_called_once() 66 | MockSubscriptionStorage.export_subscriptions.assert_called_once() 67 | 68 | @skip('This test is being skipped for the current PR') 69 | def test_run_calls_list(self): 70 | raw_json = ImmutableMultiDict(self.data["run|calls_list"][0]) 71 | with patch.object(self.logger, "log_command") as mock_logger: 72 | with patch.object(self.runner, 73 | "run_list_command") as mock_function: 74 | self.runner.run(raw_json) 75 | mock_function.assert_called_once_with( 76 | current_channel="#example-channel") 77 | mock_logger.assert_not_called() 78 | 79 | @skip('This test is being skipped for the current PR') 80 | def test_run_calls_help(self): 81 | raw_json = ImmutableMultiDict(self.data["run|calls_help"][0]) 82 | with patch.object(self.logger, "log_command") as mock_logger: 83 | with patch.object(self.runner, 84 | "run_help_command") as mock_function: 85 | self.runner.run(raw_json) 86 | mock_function.assert_called_once() 87 | mock_logger.assert_not_called() 88 | 89 | @skip('This test is being skipped for the current PR') 90 | def test_run_doesnt_call(self): 91 | with patch.object(self.logger, "log_command") as mock_logger: 92 | # Wrong command 93 | raw_json = ImmutableMultiDict(self.data["run|doesnt_call"][0]) 94 | self.assertIsNone(self.runner.run(raw_json)) 95 | 96 | # No args for subscribe or unsubscribe 97 | raw_json = ImmutableMultiDict(self.data["run|doesnt_call"][1]) 98 | self.assertIsNone(self.runner.run(raw_json)) 99 | raw_json = ImmutableMultiDict(self.data["run|doesnt_call"][2]) 100 | self.assertIsNone(self.runner.run(raw_json)) 101 | mock_logger.assert_not_called() 102 | 103 | @skip('This test is being skipped for the current PR') 104 | def test_unsubscribe_single_event(self): 105 | response = self.runner.run_unsubscribe_command( 106 | "#selene", 107 | ["github-slack-bot", "isc"], 108 | ) 109 | 110 | self.assertTrue(*Comparators.list_messages( 111 | self.data["run_unsubscribe_command|single_event"][1], 112 | response, 113 | )) 114 | 115 | @skip('This test is being skipped for the current PR') 116 | def test_unsubscribe_single_events(self): 117 | response = self.runner.run_unsubscribe_command( 118 | "#selene", 119 | ["github-slack-bot", "isc", "p"], 120 | ) 121 | 122 | self.assertTrue(*Comparators.list_messages( 123 | self.data["run_unsubscribe_command|single_events"][1], 124 | response, 125 | )) 126 | 127 | @skip('This test is being skipped for the current PR') 128 | def test_unsubscribe_single_noargs(self): 129 | response = self.runner.run_unsubscribe_command( 130 | "#selene", 131 | ["github-slack-bot"], 132 | ) 133 | 134 | self.assertTrue(*Comparators.list_messages( 135 | self.data["run_unsubscribe_command|single_noargs"][1], 136 | response, 137 | )) 138 | 139 | @skip('This test is being skipped for the current PR') 140 | def test_unsubscribe_single_all(self): 141 | response = self.runner.run_unsubscribe_command( 142 | "#selene", 143 | ["github-slack-bot", "*"], 144 | ) 145 | 146 | self.assertTrue(*Comparators.list_messages( 147 | self.data["run_unsubscribe_command|single_all"][1], 148 | response, 149 | )) 150 | 151 | @skip('This test is being skipped for the current PR') 152 | def test_unsubscribe_multiple_event(self): 153 | self.runner.subscriptions = subscriptions_deserializer( 154 | self.data["run_unsubscribe_command|multiple_event"][0]) 155 | 156 | response = self.runner.run_unsubscribe_command( 157 | "#selene", 158 | ["github-slack-bot", "isc"], 159 | ) 160 | 161 | self.assertTrue(*Comparators.list_messages( 162 | self.data["run_unsubscribe_command|multiple_event"][1], 163 | response, 164 | )) 165 | 166 | @skip('This test is being skipped for the current PR') 167 | def test_unsubscribe_multiple_events(self): 168 | self.runner.subscriptions = subscriptions_deserializer( 169 | self.data["run_unsubscribe_command|multiple_event"][0]) 170 | # Reuse subscriptions data 171 | 172 | response = self.runner.run_unsubscribe_command( 173 | "#selene", 174 | ["github-slack-bot", "isc", "p"], 175 | ) 176 | 177 | self.assertTrue(*Comparators.list_messages( 178 | self.data["run_unsubscribe_command|multiple_events"][1], 179 | response, 180 | )) 181 | 182 | @skip('This test is being skipped for the current PR') 183 | def test_unsubscribe_multiple_noargs(self): 184 | self.runner.subscriptions = subscriptions_deserializer( 185 | self.data["run_unsubscribe_command|multiple_event"][0]) 186 | # Reuse subscriptions data 187 | 188 | response = self.runner.run_unsubscribe_command( 189 | "#selene", 190 | ["github-slack-bot"], 191 | ) 192 | 193 | self.assertTrue(*Comparators.list_messages( 194 | self.data["run_unsubscribe_command|multiple_noargs"][1], 195 | response, 196 | )) 197 | 198 | @skip('This test is being skipped for the current PR') 199 | def test_unsubscribe_multiple_all(self): 200 | self.runner.subscriptions = subscriptions_deserializer( 201 | self.data["run_unsubscribe_command|multiple_event"][0]) 202 | # Reuse subscriptions data 203 | 204 | response = self.runner.run_unsubscribe_command( 205 | "#selene", 206 | ["github-slack-bot", "*"], 207 | ) 208 | 209 | self.assertTrue(*Comparators.list_messages( 210 | self.data["run_unsubscribe_command|multiple_all"][1], 211 | response, 212 | )) 213 | 214 | def test_list_empty(self): 215 | # Normal 216 | response = self.runner.run_list_command("workspace#example-channel") 217 | 218 | self.assertEqual( 219 | self.data["run_list_command|empty"][1], 220 | response, 221 | ) 222 | 223 | # Quiet 224 | response = self.runner.run_list_command( 225 | "workspace#example-channel", 226 | ephemeral=True, 227 | ) 228 | 229 | self.assertEqual( 230 | self.data["run_list_command|empty_quiet"][1], 231 | response, 232 | ) 233 | 234 | @skip('This test is being skipped for the current PR') 235 | def test_list_default(self): 236 | response = self.runner.run_list_command("#selene") 237 | 238 | self.assertTrue(*Comparators.list_messages( 239 | self.data["run_list_command|default"][1], response)) 240 | 241 | @skip('This test is being skipped for the current PR') 242 | def test_list_missing(self): 243 | response = self.runner.run_list_command("#example-channel") 244 | 245 | self.assertTrue(*Comparators.list_messages( 246 | self.data["run_list_command|missing"][1], response)) 247 | 248 | @skip('This test is being skipped for the current PR') 249 | def test_list_multiple_channels(self): 250 | self.runner.subscriptions["example-repo"] = { 251 | Channel("#example-channel", convert_keywords_to_events([])) 252 | } 253 | 254 | response = self.runner.run_list_command("#example-channel") 255 | 256 | self.assertTrue(*Comparators.list_messages( 257 | self.data["run_list_command|multiple_channels"][1], response)) 258 | 259 | @skip('This test is being skipped for the current PR') 260 | def test_list_multiple_repos(self): 261 | self.runner.subscriptions["example-repo"] = { 262 | Channel("#selene", convert_keywords_to_events([])) 263 | } 264 | 265 | response = self.runner.run_list_command("#selene") 266 | 267 | self.assertTrue(*Comparators.list_messages( 268 | self.data["run_list_command|multiple_repos"][1], response)) 269 | 270 | @skip('This test is being skipped for the current PR') 271 | def test_list_overlapping(self): 272 | self.runner.subscriptions["example-repo"] = { 273 | Channel("#example-channel", convert_keywords_to_events([])) 274 | } 275 | self.runner.subscriptions["github-slack-bot"].add( 276 | Channel("#example-channel", convert_keywords_to_events(["*"]))) 277 | 278 | response = self.runner.run_list_command("#example-channel") 279 | 280 | self.assertTrue(*Comparators.list_messages( 281 | self.data["run_list_command|overlapping"][1], response)) 282 | 283 | @skip('This test is being skipped for the current PR') 284 | def test_help(self): 285 | response = self.runner.run_help_command([]) 286 | self.assertEqual(self.data["run_help_command"][1], response) 287 | 288 | 289 | if __name__ == '__main__': 290 | unittest.main() 291 | -------------------------------------------------------------------------------- /tests/test_utils/comparators.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Any 3 | 4 | 5 | def extract_letter_sequences(string: str) -> list[str]: 6 | letter_sequence_regexp = re.compile(r'[\w\-_]+') 7 | return sorted(letter_sequence_regexp.findall(string.lower())) 8 | 9 | 10 | class Comparators: 11 | 12 | @staticmethod 13 | def list_messages(message_1: dict[str, Any], 14 | message_2: dict[str, Any]) -> tuple[bool, str]: 15 | try: 16 | if message_1["response_type"] != message_2["response_type"]: 17 | return False, "response type differs" 18 | if len(message_1["blocks"]) != len(message_2["blocks"]): 19 | return False, f"different number of blocks {len(message_1['blocks'])} vs {len(message_2['blocks'])}" 20 | 21 | for block_1, block_2 in zip(message_1["blocks"], 22 | message_2["blocks"]): 23 | if block_1["type"] != block_2["type"]: 24 | return False, "block type differs" 25 | if block_1["type"] == "section": 26 | sub_block_1, sub_block_2 = block_1["text"], block_2["text"] 27 | if sub_block_1["type"] != sub_block_2["type"]: 28 | return False, "sub-block type differs" 29 | if extract_letter_sequences( 30 | sub_block_1["text"]) != extract_letter_sequences( 31 | sub_block_2["text"]): 32 | return False, f"content differs {sub_block_1['text']} vs {sub_block_2['text']}" 33 | except KeyError: 34 | # If anything is missing 35 | return False, "missing key" 36 | 37 | # If everything's alright 38 | return True, "" 39 | -------------------------------------------------------------------------------- /tests/test_utils/deserializers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from bot.models.github import convert_keywords_to_events 4 | from bot.models.slack import Channel 5 | 6 | 7 | def github_payload_deserializer(json: dict[str, Any]): 8 | return json["event_type"], json["raw_json"] 9 | 10 | 11 | def subscriptions_deserializer(json: dict[str, dict[str, list[str]]]): 12 | return { 13 | repo: { 14 | Channel( 15 | name=channel, 16 | events=convert_keywords_to_events(events), 17 | ) 18 | for channel, events in channels.items() 19 | } 20 | for repo, channels in json.items() 21 | } 22 | -------------------------------------------------------------------------------- /tests/test_utils/load.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Any 4 | 5 | 6 | def load_test_data(module_path: str) -> Any: 7 | """ 8 | Fetches data for a test case. 9 | :param module_path: Relative path to the module of the unittest. 10 | :return: Raw data for the requested test. 11 | """ 12 | file_path = f"tests/{module_path}/data.json" # Run from root 13 | if os.path.exists(file_path): 14 | with open(file_path, encoding="utf-8") as file: 15 | return json.load(file) 16 | raise IOError(f"File 'tests/{module_path}/data.json' can't be found") 17 | -------------------------------------------------------------------------------- /tests/test_utils/serializers.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from bot.models.github.event import GitHubEvent 4 | 5 | 6 | def github_event_serializer(github_event: GitHubEvent) -> dict[str, Any]: 7 | serialized = {} 8 | for var, value in vars(github_event).items(): 9 | if isinstance(value, (list, tuple, set)): 10 | serialized[var] = [str(v) for v in value] 11 | else: 12 | serialized[var] = str(value) 13 | return serialized 14 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdgspace/github-slack-bot/aa1d2daede5658d0d6adb1c6227bd3381f4b5d93/tests/utils/__init__.py -------------------------------------------------------------------------------- /tests/utils/test_json.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from werkzeug.datastructures import ImmutableMultiDict 4 | 5 | from bot.utils.json import JSON 6 | 7 | 8 | class JSONTest(unittest.TestCase): 9 | 10 | def test_getitem_empty(self): 11 | json = JSON({}) 12 | self.assertEqual("NAME", json["name"]) 13 | 14 | def test_getitem_not_found(self): 15 | json = JSON({"login": "exampleuser"}) 16 | self.assertEqual("NAME", json["name"]) 17 | 18 | def test_getitem_found(self): 19 | json = JSON({"name": "exampleuser"}) 20 | self.assertEqual("exampleuser", json["name"]) 21 | 22 | def test_getitem_found_first(self): 23 | json = JSON({"name": "exampleuser"}) 24 | self.assertEqual("exampleuser", json["name", "login"]) 25 | 26 | def test_getitem_found_second(self): 27 | json = JSON({"login": "exampleuser"}) 28 | self.assertEqual("exampleuser", json["name", "login"]) 29 | 30 | def test_getitem_multiple_not_found(self): 31 | json = JSON({}) 32 | self.assertEqual("NAME", json["name", "login"]) 33 | 34 | def test_from_multi_dict(self): 35 | multi_dict = ImmutableMultiDict({ 36 | "name": "exampleuser", 37 | "login": "example_user" 38 | }) 39 | 40 | json = JSON.from_multi_dict(multi_dict) 41 | 42 | self.assertEqual({ 43 | "name": "exampleuser", 44 | "login": "example_user" 45 | }, json.data) 46 | --------------------------------------------------------------------------------