├── .env.example ├── .gitignore ├── .swift-version ├── LICENSE ├── Package.resolved ├── Package.swift ├── Procfile ├── README.md ├── Sources ├── App │ ├── Controllers │ │ └── NetworkInterface.swift │ ├── Migrations │ │ └── CreateSubmittedPullRequest.swift │ ├── Models │ │ ├── Responses │ │ │ ├── GithubLabelsResponse.swift │ │ │ ├── SlackEvents.swift │ │ │ ├── SlackHistoryContentRequest.swift │ │ │ └── SlackMessageResponse.swift │ │ ├── SpecialEvents │ │ │ ├── SpecialEvent.swift │ │ │ └── SpecialEvents.swift │ │ └── SubmittedPullRequest.swift │ ├── configure.swift │ └── routes.swift └── Run │ └── main.swift ├── Tests ├── AppTests │ └── AppTests.swift └── LinuxMain.swift └── app.json /.env.example: -------------------------------------------------------------------------------- 1 | SLACK_TOKEN=Slack token 2 | SIGNING_SECRET=Slack secret 3 | BANKER_TOKEN=Banker secret 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Packages 2 | .build 3 | xcuserdata 4 | *.xcodeproj 5 | DerivedData/ 6 | .DS_Store 7 | db.sqlite 8 | .swiftpm 9 | 10 | /Rudolph API.paw 11 | .env 12 | -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 5.3 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "async-http-client", 6 | "repositoryURL": "https://github.com/swift-server/async-http-client.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "4b4d6605aa2e4f0c2ae3c7563795ae3bec259fff", 10 | "version": "1.2.1" 11 | } 12 | }, 13 | { 14 | "package": "async-kit", 15 | "repositoryURL": "https://github.com/vapor/async-kit.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "7457413e57dbfac762b32dd30c1caf2c55a02a3d", 19 | "version": "1.2.0" 20 | } 21 | }, 22 | { 23 | "package": "console-kit", 24 | "repositoryURL": "https://github.com/vapor/console-kit.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "7cf8185ad62d50ae9777ce78bcfde8f5c9f900e2", 28 | "version": "4.2.1" 29 | } 30 | }, 31 | { 32 | "package": "CryptoSwift", 33 | "repositoryURL": "https://github.com/krzyzanowskim/CryptoSwift.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "af1b58fc569bfde777462349b9f7314b61762be0", 37 | "version": "1.3.2" 38 | } 39 | }, 40 | { 41 | "package": "fluent", 42 | "repositoryURL": "https://github.com/vapor/fluent.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "e681c93df3201a2d8ceef15e8a9a0634578df233", 46 | "version": "4.0.0" 47 | } 48 | }, 49 | { 50 | "package": "fluent-kit", 51 | "repositoryURL": "https://github.com/vapor/fluent-kit.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "ef2a9a76c48856f0388ee2026d67d2ebde8c571c", 55 | "version": "1.7.3" 56 | } 57 | }, 58 | { 59 | "package": "fluent-postgres-driver", 60 | "repositoryURL": "https://github.com/vapor/fluent-postgres-driver.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "f310f8843fafa3134cf89d81b8b3480f16967f37", 64 | "version": "2.1.0" 65 | } 66 | }, 67 | { 68 | "package": "postgres-kit", 69 | "repositoryURL": "https://github.com/vapor/postgres-kit.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "cbbe3ef8a0a8800301b8b76ab0f09dfc9e7306a2", 73 | "version": "2.2.0" 74 | } 75 | }, 76 | { 77 | "package": "postgres-nio", 78 | "repositoryURL": "https://github.com/vapor/postgres-nio.git", 79 | "state": { 80 | "branch": null, 81 | "revision": "3cf24967e54e3e63809593273b45b4e8135da6aa", 82 | "version": "1.4.0" 83 | } 84 | }, 85 | { 86 | "package": "routing-kit", 87 | "repositoryURL": "https://github.com/vapor/routing-kit.git", 88 | "state": { 89 | "branch": null, 90 | "revision": "4cf052b78aebaf1b23f2264ce04d57b4b6eb5254", 91 | "version": "4.2.0" 92 | } 93 | }, 94 | { 95 | "package": "sql-kit", 96 | "repositoryURL": "https://github.com/vapor/sql-kit.git", 97 | "state": { 98 | "branch": null, 99 | "revision": "ea9928b7f4a801b175a00b982034d9c54ecb6167", 100 | "version": "3.7.0" 101 | } 102 | }, 103 | { 104 | "package": "swift-backtrace", 105 | "repositoryURL": "https://github.com/swift-server/swift-backtrace.git", 106 | "state": { 107 | "branch": null, 108 | "revision": "f2fd8c4845a123419c348e0bc4b3839c414077d5", 109 | "version": "1.2.0" 110 | } 111 | }, 112 | { 113 | "package": "swift-crypto", 114 | "repositoryURL": "https://github.com/apple/swift-crypto.git", 115 | "state": { 116 | "branch": null, 117 | "revision": "afe4c9b5f65b612a2bd4b1cb5ac70eb5841f278e", 118 | "version": "1.1.0" 119 | } 120 | }, 121 | { 122 | "package": "swift-log", 123 | "repositoryURL": "https://github.com/apple/swift-log.git", 124 | "state": { 125 | "branch": null, 126 | "revision": "173f567a2dfec11d74588eea82cecea555bdc0bc", 127 | "version": "1.4.0" 128 | } 129 | }, 130 | { 131 | "package": "swift-metrics", 132 | "repositoryURL": "https://github.com/apple/swift-metrics.git", 133 | "state": { 134 | "branch": null, 135 | "revision": "708b960b4605abb20bc55d65abf6bad607252200", 136 | "version": "2.0.0" 137 | } 138 | }, 139 | { 140 | "package": "swift-nio", 141 | "repositoryURL": "https://github.com/apple/swift-nio.git", 142 | "state": { 143 | "branch": null, 144 | "revision": "96db8838be60fcbb993fa738e1120fb7f4b99c43", 145 | "version": "2.22.1" 146 | } 147 | }, 148 | { 149 | "package": "swift-nio-extras", 150 | "repositoryURL": "https://github.com/apple/swift-nio-extras.git", 151 | "state": { 152 | "branch": null, 153 | "revision": "e5b5d191a80667a14827bfeb0ae4a511f7677942", 154 | "version": "1.7.0" 155 | } 156 | }, 157 | { 158 | "package": "swift-nio-http2", 159 | "repositoryURL": "https://github.com/apple/swift-nio-http2.git", 160 | "state": { 161 | "branch": null, 162 | "revision": "c7ad256b3e6f8d1da1e5d7ffc495d8fe2461f818", 163 | "version": "1.14.2" 164 | } 165 | }, 166 | { 167 | "package": "swift-nio-ssl", 168 | "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", 169 | "state": { 170 | "branch": null, 171 | "revision": "ea1dfd64193bf5af4490635a4a44c4fb43b1e1ae", 172 | "version": "2.9.1" 173 | } 174 | }, 175 | { 176 | "package": "swift-nio-transport-services", 177 | "repositoryURL": "https://github.com/apple/swift-nio-transport-services.git", 178 | "state": { 179 | "branch": null, 180 | "revision": "bb56586c4cab9a79dce6ec4738baddb5802c5de7", 181 | "version": "1.9.0" 182 | } 183 | }, 184 | { 185 | "package": "vapor", 186 | "repositoryURL": "https://github.com/vapor/vapor.git", 187 | "state": { 188 | "branch": null, 189 | "revision": "cf1651f7ff76515593f4d8ca6e6e15d2247fe255", 190 | "version": "4.29.4" 191 | } 192 | }, 193 | { 194 | "package": "websocket-kit", 195 | "repositoryURL": "https://github.com/vapor/websocket-kit.git", 196 | "state": { 197 | "branch": null, 198 | "revision": "b0736014be634475dac4c23843811257d86dcdc1", 199 | "version": "2.1.1" 200 | } 201 | } 202 | ] 203 | }, 204 | "version": 1 205 | } 206 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Rudolph", 6 | platforms: [ 7 | .macOS(.v10_15) 8 | ], 9 | dependencies: [ 10 | // 💧 A server-side Swift web framework. 11 | .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"), 12 | .package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"), 13 | .package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"), 14 | .package(url: "https://github.com/krzyzanowskim/CryptoSwift.git", .upToNextMinor(from: "1.3.1")) 15 | ], 16 | targets: [ 17 | .target( 18 | name: "App", 19 | dependencies: [ 20 | .product(name: "Fluent", package: "fluent"), 21 | .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), 22 | .product(name: "Vapor", package: "vapor"), 23 | .product(name: "CryptoSwift", package: "CryptoSwift") 24 | ], 25 | swiftSettings: [ 26 | // Enable better optimizations when building in Release configuration. Despite the use of 27 | // the `.unsafeFlags` construct required by SwiftPM, this flag is recommended for Release 28 | // builds. See for details. 29 | .unsafeFlags(["-cross-module-optimization"], .when(configuration: .release)) 30 | ] 31 | ), 32 | .target(name: "Run", dependencies: [.target(name: "App")]), 33 | .testTarget(name: "AppTests", dependencies: [ 34 | .target(name: "App"), 35 | .product(name: "XCTVapor", package: "vapor"), 36 | ]) 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: Run serve --env production --hostname 0.0.0.0 --port $PORT 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rudolph 2 | [![Swift Version](https://img.shields.io/badge/Swift-5.3-orange?logo=swift)](https://swift.org) 3 | ![Heroku](https://img.shields.io/badge/Heroku-Deployed-purple?logo=heroku) 4 | [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](https://github.com/hackclub/Rudolph/blob/master/LICENSE) 5 | 6 | Giving gp to Hack Clubbers who contribute to open source! 7 | 8 | ## 🏖 The flow: 9 | 1. Post a message to scrapbook and react with the special emoji (`rudolph`) 10 | 2. Rudolph runs some basic checks on, to make sure they aren't spam Pull Requests 11 | 3. The total amount of gp you earn is calculated which is **15gp** plus any events that the PR is eligible for 12 | 4. That is submitted to the review channel to be checked to see if it is a spam PR 13 | 5. Your gp is given to you by the friendly `bankerapi` bot! 14 | 15 | ### ⚙️ Setup: 16 | 1. Install Swift: https://swift.org/download/#releases It's available for OS X/Windows/Linux and the toolchain doesn't take up too much space. 17 | 3. You also need to have vapor installed: `brew install vapor` on OS X, or [install it for Linux](https://docs.vapor.codes/4.0/install/linux/). 18 | 4. To use the database, you need to also need to install postgres: `brew install postgres` and make a user called "rudolph". There's no password. 19 | 5. Finally, you need to have the slack bot set up: 20 | - It needs the scopes: 21 | - `channel:read` 22 | - `channel:history` 23 | - `chat:write` 24 | - `reactions:read` 25 | - Set the events api up, pointing it at `https://[YOUR SERVER].com/slack/events` 26 | - The only event subscription it needs is the `reactions_read.` 27 | 6. Finally, finally, you need to fill in all the information in `.env.example`. To check that it can build, you can just fill it with nonsense, but, of course, it won't run/talk with slack. 28 | -------------------------------------------------------------------------------- /Sources/App/Controllers/NetworkInterface.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkInterface.swift 3 | // 4 | // Adapted from SlackKit: http://github.com/pvzig/SlackKit 5 | // 6 | // Copyright © 2017 Peter Zignego. All rights reserved. 7 | // 8 | // Permission is hereby granted, free of charge, to any person obtaining a copy 9 | // of this software and associated documentation files (the "Software"), to deal 10 | // in the Software without restriction, including without limitation the rights 11 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | // copies of the Software, and to permit persons to whom the Software is 13 | // furnished to do so, subject to the following conditions: 14 | // 15 | // The above copyright notice and this permission notice shall be included in 16 | // all copies or substantial portions of the Software. 17 | // 18 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | // THE SOFTWARE. 25 | #if os(Linux) 26 | import Dispatch 27 | #endif 28 | #if canImport(FoundationNetworking) 29 | import FoundationNetworking 30 | #endif 31 | import Foundation 32 | import Vapor 33 | 34 | public struct NetworkInterface { 35 | static let shared = NetworkInterface() 36 | 37 | private let badApiUrl = "https://slack.com/api/" 38 | private let catApiUrl = "https://api.github.com/graphql" 39 | #if canImport(FoundationNetworking) 40 | private let session = FoundationNetworking.URLSession(configuration: .default) 41 | #else 42 | private let session = URLSession(configuration: .default) 43 | #endif 44 | 45 | func sendMessage(text: String, channel: String, ts: String?, completion: ((String?) -> Void)?) { 46 | var parameters = ["token": Environment.get("SLACK_TOKEN")!, "text": text, "channel": channel] 47 | if let ts = ts { 48 | parameters["thread_ts"] = ts 49 | } 50 | guard let url = requestURL(for: "chat.postMessage", parameters: parameters) else { 51 | return 52 | } 53 | let request = URLRequest(url: url) 54 | 55 | session.dataTask(with: request) { data, _, publicError in 56 | guard publicError == nil else { 57 | if let completion = completion { 58 | completion(nil) 59 | } 60 | return 61 | } 62 | if let completion = completion { 63 | let jsonDecoder = JSONDecoder() 64 | completion(try! jsonDecoder.decode(SlackMessageResponse.self, from: data!).ts) 65 | } 66 | }.resume() 67 | } 68 | 69 | func getMessage(channel: String, ts: String, completion: @escaping (String?) -> Void) { 70 | let parameters = ["token": Environment.get("SLACK_TOKEN")!, "channel": channel, "latest": ts, "limit": "1", "inclusive": "true"] 71 | guard let url = requestURL(for: "conversations.history", parameters: parameters) else { 72 | return 73 | } 74 | let request = URLRequest(url: url) 75 | 76 | session.dataTask(with: request) { data, _, publicError in 77 | guard publicError == nil else { 78 | completion(nil) 79 | return 80 | } 81 | let jsonDecoder = JSONDecoder() 82 | completion(try! jsonDecoder.decode(SlackHistoryContentResponse.self, from: data!).messages.first!.text) 83 | 84 | }.resume() 85 | } 86 | 87 | func getPrStatus(githubOwner: String, githubRepoName: String, githubPrNumber: Int, completion: @escaping (GithubLabelsResponse?) -> Void) { 88 | let query = "{\"query\": \"query{repository(name:\\\"\(githubRepoName)\\\",owner:\\\"\(githubOwner)\\\"){pullRequest(number:\(githubPrNumber)){state author{login}labels(first:10){nodes{name}}}}}\"}" // This is me being mean to your eyes 89 | let url = URL(string: "\(catApiUrl)")! 90 | var request = URLRequest(url: url) 91 | request.httpMethod = "POST" 92 | request.addValue("bearer \(Environment.get("GITHUBAPI_TOKEN")!)", forHTTPHeaderField: "Authorization") 93 | request.httpBody = query.data(using: .utf8, allowLossyConversion: true) 94 | session.dataTask(with: request) { data, _, error in 95 | guard error == nil else { 96 | completion(nil) 97 | return 98 | } 99 | let jsonDecoder = JSONDecoder() 100 | completion(try? jsonDecoder.decode(GithubLabelsResponse.self, from: data!)) 101 | }.resume() 102 | } 103 | 104 | func sendGp(sendId: String, reason: String, amount: Int) { 105 | let url = URL(string: "https://bankerapi.glitch.me/give")! 106 | var request = URLRequest(url: url) 107 | request.httpMethod = "POST" 108 | request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") 109 | let bodyObject: [String: Any] = [ 110 | "send_id": sendId, 111 | "reason": reason, 112 | "gp": amount, 113 | "token": Environment.get("BANKER_TOKEN")!, 114 | "bot_id": "U01C6M4TFUZ", 115 | ] 116 | request.httpBody = try! JSONSerialization.data(withJSONObject: bodyObject, options: []) 117 | session.dataTask(with: request, completionHandler: { (data: Data?, _: URLResponse?, error: Error?) -> Void in 118 | let dataString = String(data: data!, encoding: .utf8)! 119 | if error != nil || dataString != "OK" { 120 | NetworkInterface.shared.sendMessage(text: ":skull-ios: I couldn't send gp to <@\(sendId)>. They were supposed to get \(amount), for \(reason). :hankey: The error was \(error?.localizedDescription ?? "no error") and the data was \(dataString) :roblox_oof:", channel: "G01BU5Y0EAE", ts: nil, completion: nil) 121 | } 122 | }).resume() 123 | } 124 | 125 | internal init() {} 126 | 127 | private func requestURL(for endpoint: String, parameters: [String: Any?]) -> URL? { 128 | var components = URLComponents(string: "\(badApiUrl)\(endpoint)") 129 | if parameters.count > 0 { 130 | components?.queryItems = parameters.compactMapValues({ $0 }).map { URLQueryItem(name: $0.0, value: "\($0.1)") } 131 | } 132 | 133 | // As discussed http://www.openradar.me/24076063 and https://stackoverflow.com/a/37314144/407523, Apple considers 134 | // a + and ? as valid characters in a URL query string, but Slack has recently started enforcing that they be 135 | // encoded when included in a query string. As a result, we need to manually apply the encoding after Apple's 136 | // default encoding is applied. 137 | var encodedQuery = components?.percentEncodedQuery 138 | encodedQuery = encodedQuery?.replacingOccurrences(of: ">", with: "%3E") 139 | encodedQuery = encodedQuery?.replacingOccurrences(of: "<", with: "%3C") 140 | encodedQuery = encodedQuery?.replacingOccurrences(of: "@", with: "%40") 141 | 142 | encodedQuery = encodedQuery?.replacingOccurrences(of: "?", with: "%3F") 143 | encodedQuery = encodedQuery?.replacingOccurrences(of: "+", with: "%2B") 144 | components?.percentEncodedQuery = encodedQuery 145 | 146 | return components?.url 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Sources/App/Migrations/CreateSubmittedPullRequest.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | 3 | struct CreateSubmittedPullRequest: Migration { 4 | func prepare(on database: Database) -> EventLoopFuture { 5 | return database.schema("submittedpullrequest") 6 | .id() 7 | .field("slackID", .string, .required) 8 | .field("isValid", .bool, .required) 9 | .field("isApproved", .bool, .required) 10 | .field("githubPrOrg", .string, .required) 11 | .field("githubPrRepoName", .string, .required) 12 | .field("githubPrID", .int, .required) 13 | .field("reason", .string, .required) 14 | .field("gpGiven", .int, .required) 15 | .field("events", .array(of: .makeOptionalType(.string)), .required) 16 | .field("slackTs", .string, .required) 17 | .field("reviewTs", .string, .required) 18 | .create() 19 | } 20 | 21 | func revert(on database: Database) -> EventLoopFuture { 22 | return database.schema("submittedpullrequest").delete() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/App/Models/Responses/GithubLabelsResponse.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // TODO: This is pretty dirty, could someone clean it up? 5 | // 6 | // Created by Linus Skucas on 10/15/20. 7 | // 8 | 9 | import Foundation 10 | 11 | struct GithubLabelsResponse: Decodable { 12 | let data: GithubLabelsRepoResponse 13 | } 14 | 15 | struct GithubLabelsRepoResponse: Decodable { 16 | let repository: GithubLabelsPrResponse 17 | } 18 | 19 | struct GithubLabelsPrResponse: Decodable { 20 | let pullRequest: GithubLabelsPrContentResponse 21 | } 22 | 23 | struct GithubLabelsPrContentResponse: Decodable { 24 | let state: GithubLabelsPrState 25 | let labels: GithubLabelsPrLabelsResponse 26 | let author: GithubLabelsPrAuthorResponse 27 | } 28 | 29 | struct GithubLabelsPrAuthorResponse: Decodable { 30 | let login: String 31 | } 32 | 33 | struct GithubLabelsPrLabelsResponse: Decodable { 34 | let nodes: [[String: String]?] 35 | } 36 | 37 | enum GithubLabelsPrState: String, Decodable { 38 | case merged = "MERGED" 39 | case open = "OPEN" 40 | case closed = "CLOSED" 41 | } 42 | -------------------------------------------------------------------------------- /Sources/App/Models/Responses/SlackEvents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Linus Skucas on 10/3/20. 6 | // 7 | 8 | import Vapor 9 | 10 | struct SlackEventType: Content { 11 | var type: String 12 | var token: String 13 | var event: SlackEventReactionAdded? = nil 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case type 17 | case token 18 | case event 19 | } 20 | } 21 | 22 | struct SlackEventVerification: Content { 23 | var challenge: String 24 | 25 | enum CodingKeys: String, CodingKey { 26 | case challenge 27 | } 28 | } 29 | 30 | 31 | struct SlackEventReactionAdded: Content { 32 | var item_user: String 33 | var reaction: String 34 | var item: [String: String] 35 | var type: String 36 | 37 | enum CodingKeys: String, CodingKey { 38 | case item_user 39 | case reaction 40 | case item 41 | case type 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/App/Models/Responses/SlackHistoryContentRequest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Linus Skucas on 10/4/20. 6 | // 7 | 8 | import Foundation 9 | import Vapor 10 | 11 | struct SlackHistoryContentResponse: Content { 12 | var messages: [SlackHistoryContentResponseMessages] 13 | enum CodingKeys: String, CodingKey { 14 | case messages 15 | } 16 | } 17 | 18 | struct SlackHistoryContentResponseMessages: Content { 19 | var text: String 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case text 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/App/Models/Responses/SlackMessageResponse.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Vapor 3 | 4 | struct SlackMessageResponse: Content { 5 | var ts: String 6 | 7 | enum CodingKeys: String, CodingKey { 8 | case ts 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/App/Models/SpecialEvents/SpecialEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Linus Skucas on 9/30/20. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SpecialEvent { 11 | var id: UUID { get } 12 | var name: String { get } 13 | var gpAdded: Int { get } 14 | 15 | func validationForPullRequest(_ pullRequestNumber: Int, repositoryName: String, repositoryOrganization: String) -> Bool 16 | } 17 | -------------------------------------------------------------------------------- /Sources/App/Models/SpecialEvents/SpecialEvents.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Linus Skucas on 10/4/20. 6 | // 7 | 8 | import Foundation 9 | 10 | let specialEvents: [SpecialEvent] = [] 11 | -------------------------------------------------------------------------------- /Sources/App/Models/SubmittedPullRequest.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import Vapor 3 | 4 | final class SubmittedPullRequest: Model, Content { 5 | static let schema = "submittedpullrequest" 6 | 7 | @ID(key: .id) 8 | var id: UUID? 9 | 10 | @Field(key: "slackID") 11 | var slackID: String 12 | 13 | @Field(key: "isValid") 14 | var isValid: Bool 15 | 16 | @Field(key: "isApproved") 17 | var isApproved: Bool 18 | 19 | @Field(key: "githubPrOrg") 20 | var githubPrOrg: String 21 | 22 | @Field(key: "githubPrRepoName") 23 | var githubPrRepoName: String 24 | 25 | @Field(key: "githubPrID") 26 | var githubPrID: Int 27 | 28 | @Field(key: "slackTs") 29 | var slackTs: String 30 | 31 | @Field(key: "reason") 32 | var reason: String 33 | 34 | @Field(key: "gpGiven") 35 | var gpGiven: Int 36 | 37 | @OptionalField(key: "events") 38 | var events: [UUID]? 39 | 40 | @Field(key: "reviewTs") 41 | var reviewTs: String 42 | 43 | init() { } 44 | 45 | init(id: UUID? = UUID(), slackID: String, isValid: Bool, isApproved: Bool, githubPrOrg: String, githubPrRepoName: String, githubPrID: Int, slackTs: String, reason: String, gpGiven: Int, events: [UUID]?, reviewTs: String) { 46 | self.id = id 47 | self.slackID = slackID 48 | self.isValid = isValid 49 | self.isApproved = isApproved 50 | self.githubPrOrg = githubPrOrg 51 | self.githubPrRepoName = githubPrRepoName 52 | self.githubPrID = githubPrID 53 | self.slackTs = slackTs 54 | self.reason = reason 55 | self.gpGiven = gpGiven 56 | self.events = events 57 | self.reviewTs = reviewTs 58 | } 59 | } 60 | 61 | -------------------------------------------------------------------------------- /Sources/App/configure.swift: -------------------------------------------------------------------------------- 1 | import Fluent 2 | import FluentPostgresDriver 3 | import Vapor 4 | 5 | // configures your application 6 | public func configure(_ app: Application) throws { 7 | // uncomment to serve files from /Public folder 8 | // app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) 9 | 10 | if let databaseURL = Environment.get("DATABASE_URL"), var postgresConfig = PostgresConfiguration(url: databaseURL) { 11 | postgresConfig.tlsConfiguration = .forClient(certificateVerification: .none) 12 | app.databases.use(.postgres( 13 | configuration: postgresConfig 14 | ), as: .psql) 15 | } else { 16 | app.databases.use(.postgres( 17 | hostname: Environment.get("DATABASE_HOST") ?? "localhost", 18 | username: Environment.get("DATABASE_USERNAME") ?? "rudolph", 19 | password: Environment.get("DATABASE_PASSWORD") ?? "", 20 | database: Environment.get("DATABASE_NAME") ?? "rudolph" 21 | ), as: .psql) 22 | } 23 | 24 | app.migrations.add(CreateSubmittedPullRequest()) 25 | 26 | // register routes 27 | try routes(app) 28 | } 29 | -------------------------------------------------------------------------------- /Sources/App/routes.swift: -------------------------------------------------------------------------------- 1 | import CryptoSwift 2 | import Fluent 3 | import Vapor 4 | 5 | let emoji = "rudolph" 6 | 7 | func routes(_ app: Application) throws { 8 | app.post("slack", "events") { req -> String in 9 | guard verifySignature(for: req) else { throw Abort(.imATeapot) } 10 | let contentType = try req.content.decode(SlackEventType.self) 11 | 12 | if contentType.type == "url_verification" { 13 | let content = try req.content.decode(SlackEventVerification.self) 14 | return content.challenge 15 | } else if contentType.type == "event_callback" { 16 | // reaction added 17 | let event = contentType.event! 18 | guard event.type == "reaction_added" else { return "" } 19 | if event.item["channel"]! == "G01C9MTKXU1" || event.item["channel"]! == "C01504DCLVD" || event.item["channel"]! == "C01DKAXDFA9" { // If we're in the testing or scrapbook channel TODO; Should we make this available anywhere on slack? 20 | guard event.reaction == emoji else { return "" } // TODO: Create custom emoji 21 | let slackTss = SubmittedPullRequest.query(on: req.db).all(\.$slackTs) 22 | slackTss.whenSuccess { tss in 23 | guard !tss.contains(event.item["ts"]!) else { return } 24 | // Check for a GitHub link, if it doesn't match notify 25 | let pattern = #"(https|http):\/\/github\.com\/(?.*)\/(?.*)\/pull\/(?\d*)"# 26 | NetworkInterface.shared.getMessage(channel: event.item["channel"]!, ts: event.item["ts"]!) { message in 27 | guard let message = message else { return } 28 | let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) 29 | guard let match = regex?.firstMatch(in: message, options: [], range: NSRange(location: 0, length: message.utf16.count)) else { 30 | NetworkInterface.shared.sendMessage(text: "Waaaa.... that doesn't look like a github link to me! Make sure you're linking the pull request!", channel: event.item["channel"]!, ts: event.item["ts"]!, completion: nil) 31 | return 32 | } 33 | guard let ownerRange = Range(match.range(withName: "owner"), in: message), 34 | let repoRange = Range(match.range(withName: "repo"), in: message), 35 | let numberRange = Range(match.range(withName: "number"), in: message) else { 36 | NetworkInterface.shared.sendMessage(text: "Waaaa.... that doesn't look like a github link to meeee! Make sure you're linking the pull request!", channel: event.item["channel"]!, ts: event.item["ts"]!, completion: nil) 37 | return 38 | } 39 | let ownerSubstring = message[ownerRange] 40 | let repoSubstring = message[repoRange] 41 | let numberSubstring = message[numberRange] 42 | // Start up the 🥁 Grand Central Dispatch 43 | let queue = DispatchQueue(label: "Santa's Slay") 44 | let owner = String(ownerSubstring) 45 | let repo = String(repoSubstring) 46 | let number = Int(numberSubstring)! 47 | queue.async { 48 | // post message to slack 49 | NetworkInterface.shared.sendMessage(text: "Woohoo! :yay: I'm going to chew on your PR for a little while to make sure it tastes good, but if all goes well you'll get some GP!", channel: event.item["channel"]!, ts: event.item["ts"]!, completion: nil) 50 | let submittedPullRequest = SubmittedPullRequest() 51 | submittedPullRequest.id = UUID() 52 | submittedPullRequest.slackID = event.item_user 53 | submittedPullRequest.isValid = false 54 | submittedPullRequest.isApproved = false 55 | submittedPullRequest.githubPrOrg = owner 56 | submittedPullRequest.githubPrRepoName = repo 57 | submittedPullRequest.githubPrID = number 58 | submittedPullRequest.slackTs = event.item["ts"]! 59 | submittedPullRequest.reason = "" 60 | submittedPullRequest.gpGiven = 15 61 | submittedPullRequest.reviewTs = "" 62 | submittedPullRequest.events = [] 63 | submittedPullRequest.create(on: req.db) 64 | // Calculate gp 65 | specialEvents.forEach { specialEvent in 66 | if specialEvent.validationForPullRequest(submittedPullRequest.githubPrID, repositoryName: submittedPullRequest.githubPrRepoName, repositoryOrganization: submittedPullRequest.githubPrOrg) { 67 | submittedPullRequest.gpGiven += specialEvent.gpAdded 68 | submittedPullRequest.events?.append(specialEvent.id) 69 | } 70 | } 71 | // Run Checks 72 | NetworkInterface.shared.getPrStatus(githubOwner: owner, githubRepoName: repo, githubPrNumber: number) { githubResp in 73 | guard let githubResp = githubResp else { return failWithMessage("Couldn't parse the repsitory: Are you sure it's a public Pull Request?", sendId: event.item_user) } 74 | guard !githubResp.data.repository.pullRequest.labels.nodes.contains(["name": "invalid"]) && !githubResp.data.repository.pullRequest.labels.nodes.contains(["name": "spam"]) else { 75 | NetworkInterface.shared.sendMessage(text: ":rotating_light: :rotating_light: <@\(event.item_user)>'s pull request is marked as spam/invalid, go check it out! https://github.com/\(owner)/\(repo)/pull/\(number)", channel: "G01BU5Y0EAE", ts: nil, completion: nil) 76 | return failWithMessage("That pull request was marked as spam/invalid. *DO NOT* spam pull requests in order to get fake internet points.", sendId: event.item_user) 77 | } 78 | guard githubResp.data.repository.pullRequest.state == .merged else { return failWithMessage("That Pull Request you sent doesn't look merged! Go get it merged then come to me for gp!", sendId: event.item_user) } 79 | guard githubResp.data.repository.pullRequest.author.login != owner else { return failWithMessage("oops. You can only get gp for contributing to open source projects that are outside your GitHub account!", sendId: event.item_user) } 80 | submittedPullRequest.isValid = true 81 | submittedPullRequest.save(on: req.db) 82 | // Send to review channel 83 | NetworkInterface.shared.sendMessage(text: ":carrot: :carrot: There's a new PR from <@\(event.item_user)>! :cool: Check it out: https://github.com/\(owner)/\(repo)/pull/\(number) If it looks good react with :true: , if it's :hankey:, react with :x:", channel: "G01BU5Y0EAE", ts: nil) { reviewTs in 84 | guard let reviewTs = reviewTs else { return } 85 | submittedPullRequest.reviewTs = reviewTs 86 | submittedPullRequest.save(on: req.db) 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } else if event.item["channel"]! == "G01BU5Y0EAE" { // Check if we're in the review channel 93 | // TODO: Check if the PR is accepted based on the reaction 94 | let event = contentType.event! 95 | guard event.type == "reaction_added" else { return "" } 96 | // get the db entry 97 | let reviewingPullRequest = SubmittedPullRequest.query(on: req.db) 98 | .filter(\.$reviewTs == event.item["ts"]!) 99 | .first() 100 | reviewingPullRequest.whenSuccess { reviewingPullRequest in 101 | guard let reviewingPullRequest = reviewingPullRequest else { return } 102 | guard reviewingPullRequest.isApproved != true else { return } 103 | if event.reaction == "x" { 104 | failWithMessage("Your Pull Request #\(reviewingPullRequest.githubPrID), \(reviewingPullRequest.githubPrOrg)/\(reviewingPullRequest.githubPrRepoName) was rejected. DM <@U011CFN98K1> if you have any questions.", sendId: reviewingPullRequest.slackID) 105 | } else if event.reaction == "true" { 106 | NetworkInterface.shared.sendGp(sendId: reviewingPullRequest.slackID, reason: "Good job, your Pull Request was accepted!", amount: reviewingPullRequest.gpGiven) 107 | reviewingPullRequest.isApproved = true 108 | reviewingPullRequest.save(on: req.db) 109 | NetworkInterface.shared.sendMessage(text: ":yay: <@\(reviewingPullRequest.slackID)>'s pull request was accepted!! :rudolph: :deer: :nyan: https://github.com/\(reviewingPullRequest.githubPrOrg)/\(reviewingPullRequest.githubPrRepoName)/pull/\(reviewingPullRequest.githubPrID)", channel: "C01DKAXDFA9", ts: nil, completion: nil) 110 | } 111 | } 112 | } 113 | } 114 | return "" 115 | } 116 | } 117 | 118 | func verifySignature(for request: Request) -> Bool { 119 | let timestamp = request.headers["X-Slack-Request-Timestamp"].first! 120 | let hmacConcate = "v0:\(timestamp):\(request.body.string!)" 121 | let key = Environment.get("SIGNING_SECRET")! 122 | let localSignature = "v0=\(try! HMAC(key: key, variant: .sha256).authenticate(hmacConcate.bytes).toHexString())" 123 | if localSignature == request.headers["X-Slack-Signature"].first! { 124 | return true 125 | } else { 126 | return false 127 | } 128 | } 129 | 130 | func failWithMessage(_ message: String, sendId: String) { 131 | NetworkInterface.shared.sendGp(sendId: sendId, reason: "Rudolph couldn't send you gp :kermit_scream: \(message)", amount: 0) 132 | } 133 | -------------------------------------------------------------------------------- /Sources/Run/main.swift: -------------------------------------------------------------------------------- 1 | import App 2 | import Vapor 3 | 4 | var env = try Environment.detect() 5 | try LoggingSystem.bootstrap(from: &env) 6 | let app = Application(env) 7 | defer { app.shutdown() } 8 | try configure(app) 9 | try app.run() 10 | -------------------------------------------------------------------------------- /Tests/AppTests/AppTests.swift: -------------------------------------------------------------------------------- 1 | @testable import App 2 | import XCTVapor 3 | 4 | //final class AppTests: XCTestCase { 5 | // func testHelloWorld() throws { 6 | // 7 | // try app.test(.GET, "", afterResponse: { res in 8 | // XCTAssertEqual(res.status, .ok) 9 | // 10 | // }) 11 | // } 12 | //} 13 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | var tests = [XCTestCaseEntry]() 5 | 6 | XCTMain(tests) 7 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Rudolph", 3 | "description": "Give gp to ships!", 4 | "keywords": [ 5 | "swift", 6 | "vapor", 7 | "hack", 8 | "club", 9 | "christmas", 10 | "rudolph", 11 | "reindeer", 12 | "gp" 13 | ], 14 | "repository": "https://github.com/hackclub/Rudolph", 15 | "logo": "https://cloud-ew8znyumf.vercel.app/image.png", 16 | "formation": { 17 | "web": { 18 | "quantity": 1, 19 | "size": "free" 20 | } 21 | }, 22 | "addons": ["heroku-postgresql:hobby-dev", "papertrail"], 23 | "buildpacks": [ 24 | { 25 | "url": "https://buildpack-registry.s3.amazonaws.com/buildpacks/vapor/vapor.tgz" 26 | } 27 | ] 28 | } 29 | --------------------------------------------------------------------------------