├── .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": "[](#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 | [](https://github.com/BURG3R5/github-slack-bot/actions/workflows/ci.yml)
2 | [](https://join.slack.com/t/github-slack-bot/shared_invite/zt-1ebtvtdfr-3bPrsDDBnL95hW1pIjivbw)
3 |
4 | [](#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 |
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 |
--------------------------------------------------------------------------------