├── .dockerignore ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── app.py ├── bin ├── build.sh ├── download-latest-release.py ├── insert_version.js ├── ship-release.py └── update_version.js ├── culprits.png ├── package.json ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── manifest.json ├── mstile-150x150.png └── safari-pinned-tab.svg ├── requirements.in ├── requirements.txt ├── screenshot.png ├── setup.cfg ├── src ├── App.css ├── App.js ├── App.test.js ├── AutoProgressBar.js ├── Common.js ├── DeployPage.js ├── LongUrlRedirect.js ├── Routes.js ├── SetupPage.js ├── index.js ├── serviceWorker.js ├── shortUrls.js └── static │ └── check.png └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .direnv 3 | .env* 4 | .envrc 5 | .floo* 6 | .pytest_cache 7 | build 8 | node_modules 9 | pip-cache 10 | site-packages 11 | venv 12 | webpack-stats-actions.json 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Top-most EditorConfig file ? 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | # Indentiation 11 | [*.py] 12 | indent_style = space 13 | indent_size = 4 14 | 15 | [*.{css,js,json,html,yml}] 16 | indent_style = space 17 | indent_size = 2 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: monthly 7 | open-pull-requests-limit: 10 8 | # This forces the Dependabot commit messages to conform to something 9 | # our auto-merge workflow can always cope with. 10 | # See https://github.com/ahmadnassri/action-dependabot-auto-merge/issues/31#issuecomment-718779806 11 | commit-message: 12 | prefix: build 13 | prefix-development: chore 14 | include: scope 15 | 16 | - package-ecosystem: 'github-actions' 17 | directory: '/' 18 | schedule: 19 | interval: monthly 20 | 21 | - package-ecosystem: 'pip' 22 | directory: '/' 23 | schedule: 24 | interval: monthly 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI Django and Node 2 | 3 | on: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v4 12 | 13 | - name: Setup Node.js environment 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: '18' 17 | 18 | - name: Cache node_modules 19 | uses: actions/cache@v3.3.2 20 | id: cached-node_modules 21 | with: 22 | path: | 23 | node_modules 24 | key: ${{ runner.os }}-${{ hashFiles('yarn.lock') }} 25 | 26 | - name: Install all yarn packages 27 | if: steps.cached-node_modules.outputs.cache-hit != 'true' 28 | run: | 29 | yarn --frozen-lockfile 30 | 31 | - name: Set up Python 3.8 32 | uses: actions/setup-python@v4 33 | with: 34 | python-version: '3.8' 35 | 36 | - name: Cache pip 37 | uses: actions/cache@v3.3.2 38 | with: 39 | path: ~/.cache/pip 40 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}-${{ hashFiles('dev-requirements.txt') }} 41 | restore-keys: | 42 | ${{ runner.os }}-pip- 43 | 44 | - name: Install Dependencies 45 | run: | 46 | pip install -U pip wheel --progress-bar off 47 | pip install -r requirements.txt --progress-bar off 48 | 49 | - name: Run lints 50 | run: | 51 | black --check app.py 52 | flake8 app.py 53 | yarn prettier:check 54 | 55 | - name: Build client 56 | run: | 57 | yarn run build 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # testing 5 | /coverage 6 | 7 | # production 8 | /build 9 | 10 | # misc 11 | .DS_Store 12 | .env.local 13 | .env.development.local 14 | .env.test.local 15 | .env.production.local 16 | .vscode 17 | 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | build.zip 22 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | semi: true 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6-slim 2 | 3 | WORKDIR /app/ 4 | RUN groupadd --gid 10001 app && useradd -g app --uid 10001 --shell /usr/sbin/nologin app 5 | 6 | RUN apt-get update && \ 7 | apt-get install -y gcc apt-transport-https curl gnupg 8 | 9 | # Install Node and Yarn 10 | RUN curl -s https://deb.nodesource.com/gpgkey/nodesource.gpg.key | apt-key add - && \ 11 | echo 'deb https://deb.nodesource.com/node_10.x stretch main' > /etc/apt/sources.list.d/nodesource.list && \ 12 | echo 'deb-src https://deb.nodesource.com/node_10.x stretch main' >> /etc/apt/sources.list.d/nodesource.list && \ 13 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ 14 | echo 'deb https://dl.yarnpkg.com/debian/ stable main' > /etc/apt/sources.list.d/yarn.list && \ 15 | apt-get update && \ 16 | apt-get install -y nodejs yarn 17 | 18 | COPY ./requirements.txt /app/requirements.txt 19 | COPY ./requirements-constraints.txt /app/requirements-constraints.txt 20 | COPY ./package.json /app/package.json 21 | COPY ./yarn.lock /app/yarn.lock 22 | 23 | RUN pip install -U 'pip>=8' && \ 24 | pip install --no-cache-dir -r requirements.txt --progress-bar off && \ 25 | yarn install --non-interactive --prod 26 | 27 | # Install the app 28 | COPY . /app/ 29 | RUN yarn build --prod 30 | 31 | # Set Python-related environment variables to reduce annoying-ness 32 | ENV PYTHONUNBUFFERED 1 33 | ENV PYTHONDONTWRITEBYTECODE 1 34 | ENV PORT 5000 35 | 36 | USER app 37 | 38 | EXPOSE $PORT 39 | 40 | CMD python app.py 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License, version 2.0 2 | 3 | 1. Definitions 4 | 5 | 1.1. “Contributor” 6 | 7 | means each individual or legal entity that creates, contributes to the 8 | creation of, or owns Covered Software. 9 | 10 | 1.2. “Contributor Version” 11 | 12 | means the combination of the Contributions of others (if any) used by a 13 | Contributor and that particular Contributor’s Contribution. 14 | 15 | 1.3. “Contribution” 16 | 17 | means Covered Software of a particular Contributor. 18 | 19 | 1.4. “Covered Software” 20 | 21 | means Source Code Form to which the initial Contributor has attached the 22 | notice in Exhibit A, the Executable Form of such Source Code Form, and 23 | Modifications of such Source Code Form, in each case including portions 24 | thereof. 25 | 26 | 1.5. “Incompatible With Secondary Licenses” 27 | means 28 | 29 | a. that the initial Contributor has attached the notice described in 30 | Exhibit B to the Covered Software; or 31 | 32 | b. that the Covered Software was made available under the terms of version 33 | 1.1 or earlier of the License, but not also under the terms of a 34 | Secondary License. 35 | 36 | 1.6. “Executable Form” 37 | 38 | means any form of the work other than Source Code Form. 39 | 40 | 1.7. “Larger Work” 41 | 42 | means a work that combines Covered Software with other material, in a separate 43 | file or files, that is not Covered Software. 44 | 45 | 1.8. “License” 46 | 47 | means this document. 48 | 49 | 1.9. “Licensable” 50 | 51 | means having the right to grant, to the maximum extent possible, whether at the 52 | time of the initial grant or subsequently, any and all of the rights conveyed by 53 | this License. 54 | 55 | 1.10. “Modifications” 56 | 57 | means any of the following: 58 | 59 | a. any file in Source Code Form that results from an addition to, deletion 60 | from, or modification of the contents of Covered Software; or 61 | 62 | b. any new file in Source Code Form that contains any Covered Software. 63 | 64 | 1.11. “Patent Claims” of a Contributor 65 | 66 | means any patent claim(s), including without limitation, method, process, 67 | and apparatus claims, in any patent Licensable by such Contributor that 68 | would be infringed, but for the grant of the License, by the making, 69 | using, selling, offering for sale, having made, import, or transfer of 70 | either its Contributions or its Contributor Version. 71 | 72 | 1.12. “Secondary License” 73 | 74 | means either the GNU General Public License, Version 2.0, the GNU Lesser 75 | General Public License, Version 2.1, the GNU Affero General Public 76 | License, Version 3.0, or any later versions of those licenses. 77 | 78 | 1.13. “Source Code Form” 79 | 80 | means the form of the work preferred for making modifications. 81 | 82 | 1.14. “You” (or “Your”) 83 | 84 | means an individual or a legal entity exercising rights under this 85 | License. For legal entities, “You” includes any entity that controls, is 86 | controlled by, or is under common control with You. For purposes of this 87 | definition, “control” means (a) the power, direct or indirect, to cause 88 | the direction or management of such entity, whether by contract or 89 | otherwise, or (b) ownership of more than fifty percent (50%) of the 90 | outstanding shares or beneficial ownership of such entity. 91 | 92 | 93 | 2. License Grants and Conditions 94 | 95 | 2.1. Grants 96 | 97 | Each Contributor hereby grants You a world-wide, royalty-free, 98 | non-exclusive license: 99 | 100 | a. under intellectual property rights (other than patent or trademark) 101 | Licensable by such Contributor to use, reproduce, make available, 102 | modify, display, perform, distribute, and otherwise exploit its 103 | Contributions, either on an unmodified basis, with Modifications, or as 104 | part of a Larger Work; and 105 | 106 | b. under Patent Claims of such Contributor to make, use, sell, offer for 107 | sale, have made, import, and otherwise transfer either its Contributions 108 | or its Contributor Version. 109 | 110 | 2.2. Effective Date 111 | 112 | The licenses granted in Section 2.1 with respect to any Contribution become 113 | effective for each Contribution on the date the Contributor first distributes 114 | such Contribution. 115 | 116 | 2.3. Limitations on Grant Scope 117 | 118 | The licenses granted in this Section 2 are the only rights granted under this 119 | License. No additional rights or licenses will be implied from the distribution 120 | or licensing of Covered Software under this License. Notwithstanding Section 121 | 2.1(b) above, no patent license is granted by a Contributor: 122 | 123 | a. for any code that a Contributor has removed from Covered Software; or 124 | 125 | b. for infringements caused by: (i) Your and any other third party’s 126 | modifications of Covered Software, or (ii) the combination of its 127 | Contributions with other software (except as part of its Contributor 128 | Version); or 129 | 130 | c. under Patent Claims infringed by Covered Software in the absence of its 131 | Contributions. 132 | 133 | This License does not grant any rights in the trademarks, service marks, or 134 | logos of any Contributor (except as may be necessary to comply with the 135 | notice requirements in Section 3.4). 136 | 137 | 2.4. Subsequent Licenses 138 | 139 | No Contributor makes additional grants as a result of Your choice to 140 | distribute the Covered Software under a subsequent version of this License 141 | (see Section 10.2) or under the terms of a Secondary License (if permitted 142 | under the terms of Section 3.3). 143 | 144 | 2.5. Representation 145 | 146 | Each Contributor represents that the Contributor believes its Contributions 147 | are its original creation(s) or it has sufficient rights to grant the 148 | rights to its Contributions conveyed by this License. 149 | 150 | 2.6. Fair Use 151 | 152 | This License is not intended to limit any rights You have under applicable 153 | copyright doctrines of fair use, fair dealing, or other equivalents. 154 | 155 | 2.7. Conditions 156 | 157 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 158 | Section 2.1. 159 | 160 | 161 | 3. Responsibilities 162 | 163 | 3.1. Distribution of Source Form 164 | 165 | All distribution of Covered Software in Source Code Form, including any 166 | Modifications that You create or to which You contribute, must be under the 167 | terms of this License. You must inform recipients that the Source Code Form 168 | of the Covered Software is governed by the terms of this License, and how 169 | they can obtain a copy of this License. You may not attempt to alter or 170 | restrict the recipients’ rights in the Source Code Form. 171 | 172 | 3.2. Distribution of Executable Form 173 | 174 | If You distribute Covered Software in Executable Form then: 175 | 176 | a. such Covered Software must also be made available in Source Code Form, 177 | as described in Section 3.1, and You must inform recipients of the 178 | Executable Form how they can obtain a copy of such Source Code Form by 179 | reasonable means in a timely manner, at a charge no more than the cost 180 | of distribution to the recipient; and 181 | 182 | b. You may distribute such Executable Form under the terms of this License, 183 | or sublicense it under different terms, provided that the license for 184 | the Executable Form does not attempt to limit or alter the recipients’ 185 | rights in the Source Code Form under this License. 186 | 187 | 3.3. Distribution of a Larger Work 188 | 189 | You may create and distribute a Larger Work under terms of Your choice, 190 | provided that You also comply with the requirements of this License for the 191 | Covered Software. If the Larger Work is a combination of Covered Software 192 | with a work governed by one or more Secondary Licenses, and the Covered 193 | Software is not Incompatible With Secondary Licenses, this License permits 194 | You to additionally distribute such Covered Software under the terms of 195 | such Secondary License(s), so that the recipient of the Larger Work may, at 196 | their option, further distribute the Covered Software under the terms of 197 | either this License or such Secondary License(s). 198 | 199 | 3.4. Notices 200 | 201 | You may not remove or alter the substance of any license notices (including 202 | copyright notices, patent notices, disclaimers of warranty, or limitations 203 | of liability) contained within the Source Code Form of the Covered 204 | Software, except that You may alter any license notices to the extent 205 | required to remedy known factual inaccuracies. 206 | 207 | 3.5. Application of Additional Terms 208 | 209 | You may choose to offer, and to charge a fee for, warranty, support, 210 | indemnity or liability obligations to one or more recipients of Covered 211 | Software. However, You may do so only on Your own behalf, and not on behalf 212 | of any Contributor. You must make it absolutely clear that any such 213 | warranty, support, indemnity, or liability obligation is offered by You 214 | alone, and You hereby agree to indemnify every Contributor for any 215 | liability incurred by such Contributor as a result of warranty, support, 216 | indemnity or liability terms You offer. You may include additional 217 | disclaimers of warranty and limitations of liability specific to any 218 | jurisdiction. 219 | 220 | 4. Inability to Comply Due to Statute or Regulation 221 | 222 | If it is impossible for You to comply with any of the terms of this License 223 | with respect to some or all of the Covered Software due to statute, judicial 224 | order, or regulation then You must: (a) comply with the terms of this License 225 | to the maximum extent possible; and (b) describe the limitations and the code 226 | they affect. Such description must be placed in a text file included with all 227 | distributions of the Covered Software under this License. Except to the 228 | extent prohibited by statute or regulation, such description must be 229 | sufficiently detailed for a recipient of ordinary skill to be able to 230 | understand it. 231 | 232 | 5. Termination 233 | 234 | 5.1. The rights granted under this License will terminate automatically if You 235 | fail to comply with any of its terms. However, if You become compliant, 236 | then the rights granted under this License from a particular Contributor 237 | are reinstated (a) provisionally, unless and until such Contributor 238 | explicitly and finally terminates Your grants, and (b) on an ongoing basis, 239 | if such Contributor fails to notify You of the non-compliance by some 240 | reasonable means prior to 60 days after You have come back into compliance. 241 | Moreover, Your grants from a particular Contributor are reinstated on an 242 | ongoing basis if such Contributor notifies You of the non-compliance by 243 | some reasonable means, this is the first time You have received notice of 244 | non-compliance with this License from such Contributor, and You become 245 | compliant prior to 30 days after Your receipt of the notice. 246 | 247 | 5.2. If You initiate litigation against any entity by asserting a patent 248 | infringement claim (excluding declaratory judgment actions, counter-claims, 249 | and cross-claims) alleging that a Contributor Version directly or 250 | indirectly infringes any patent, then the rights granted to You by any and 251 | all Contributors for the Covered Software under Section 2.1 of this License 252 | shall terminate. 253 | 254 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 255 | license agreements (excluding distributors and resellers) which have been 256 | validly granted by You or Your distributors under this License prior to 257 | termination shall survive termination. 258 | 259 | 6. Disclaimer of Warranty 260 | 261 | Covered Software is provided under this License on an “as is” basis, without 262 | warranty of any kind, either expressed, implied, or statutory, including, 263 | without limitation, warranties that the Covered Software is free of defects, 264 | merchantable, fit for a particular purpose or non-infringing. The entire 265 | risk as to the quality and performance of the Covered Software is with You. 266 | Should any Covered Software prove defective in any respect, You (not any 267 | Contributor) assume the cost of any necessary servicing, repair, or 268 | correction. This disclaimer of warranty constitutes an essential part of this 269 | License. No use of any Covered Software is authorized under this License 270 | except under this disclaimer. 271 | 272 | 7. Limitation of Liability 273 | 274 | Under no circumstances and under no legal theory, whether tort (including 275 | negligence), contract, or otherwise, shall any Contributor, or anyone who 276 | distributes Covered Software as permitted above, be liable to You for any 277 | direct, indirect, special, incidental, or consequential damages of any 278 | character including, without limitation, damages for lost profits, loss of 279 | goodwill, work stoppage, computer failure or malfunction, or any and all 280 | other commercial damages or losses, even if such party shall have been 281 | informed of the possibility of such damages. This limitation of liability 282 | shall not apply to liability for death or personal injury resulting from such 283 | party’s negligence to the extent applicable law prohibits such limitation. 284 | Some jurisdictions do not allow the exclusion or limitation of incidental or 285 | consequential damages, so this exclusion and limitation may not apply to You. 286 | 287 | 8. Litigation 288 | 289 | Any litigation relating to this License may be brought only in the courts of 290 | a jurisdiction where the defendant maintains its principal place of business 291 | and such litigation shall be governed by laws of that jurisdiction, without 292 | reference to its conflict-of-law provisions. Nothing in this Section shall 293 | prevent a party’s ability to bring cross-claims or counter-claims. 294 | 295 | 9. Miscellaneous 296 | 297 | This License represents the complete agreement concerning the subject matter 298 | hereof. If any provision of this License is held to be unenforceable, such 299 | provision shall be reformed only to the extent necessary to make it 300 | enforceable. Any law or regulation which provides that the language of a 301 | contract shall be construed against the drafter shall not be used to construe 302 | this License against a Contributor. 303 | 304 | 305 | 10. Versions of the License 306 | 307 | 10.1. New Versions 308 | 309 | Mozilla Foundation is the license steward. Except as provided in Section 310 | 10.3, no one other than the license steward has the right to modify or 311 | publish new versions of this License. Each version will be given a 312 | distinguishing version number. 313 | 314 | 10.2. Effect of New Versions 315 | 316 | You may distribute the Covered Software under the terms of the version of 317 | the License under which You originally received the Covered Software, or 318 | under the terms of any subsequent version published by the license 319 | steward. 320 | 321 | 10.3. Modified Versions 322 | 323 | If you create software not governed by this License, and you want to 324 | create a new license for such software, you may create and use a modified 325 | version of this License if you rename the license and remove any 326 | references to the name of the license steward (except to note that such 327 | modified license differs from this License). 328 | 329 | 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses 330 | If You choose to distribute Source Code Form that is Incompatible With 331 | Secondary Licenses under the terms of this version of the License, the 332 | notice described in Exhibit B of this License must be attached. 333 | 334 | Exhibit A - Source Code Form License Notice 335 | 336 | This Source Code Form is subject to the 337 | terms of the Mozilla Public License, v. 338 | 2.0. If a copy of the MPL was not 339 | distributed with this file, You can 340 | obtain one at 341 | http://mozilla.org/MPL/2.0/. 342 | 343 | If it is not possible or desirable to put the notice in a particular file, then 344 | You may include the notice in a location (such as a LICENSE file in a relevant 345 | directory) where a recipient would be likely to look for such a notice. 346 | 347 | You may add additional accurate notices of copyright ownership. 348 | 349 | Exhibit B - “Incompatible With Secondary Licenses” Notice 350 | 351 | This Source Code Form is “Incompatible 352 | With Secondary Licenses”, as defined by 353 | the Mozilla Public License, v. 2.0. 354 | 355 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | # To be used with `honcho`. To use, run: 2 | # 3 | # pip install honcho 4 | # 5 | # honcho start 6 | # 7 | 8 | flask: python app.py 9 | ui: PORT=3000 BROWSER=none yarn start 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What's Deployed? 2 | 3 | What's deployed from a GitHub repo on various server environments? 4 | 5 | This requires that you have 2 or more URLs that return a git sha that 6 | references which git sha has been deployed. 7 | 8 | ## Screenshots 9 | 10 | ### Main table 11 | 12 | ![Example output](screenshot.png) 13 | 14 | ### "Culprits" 15 | 16 | !["Culprits"](culprits.png) 17 | 18 | ## License 19 | 20 | [MPL 2.0](http://www.mozilla.org/MPL/2.0/) 21 | 22 | ## Credits 23 | 24 | [Checkbox icon](https://www.iconfinder.com/icons/282474/check_done_ok_icon#size=16) 25 | by [IcoCentre](https://www.iconfinder.com/konekierto). 26 | 27 | ## Development 28 | 29 | Development requires both Python 3.6 or higher for the backed, and JS for the 30 | front end. 31 | 32 | To install dependencies: 33 | 34 | ``` 35 | pip install -r requirements.txt 36 | pip install -r dev-requirements.txt 37 | yarn install 38 | ``` 39 | 40 | To run the app, first start the backend (note, you need at least Python 3.6): 41 | 42 | ``` 43 | DEBUG=1 SQLALCHEMY_DATABASE_URI='postgres:///whatsdeployed' ./app.py 44 | ``` 45 | 46 | and then in a separate terminal, start the frontend: 47 | 48 | ``` 49 | yarn start 50 | ``` 51 | 52 | This will automatically open your browser to http://localhost:3000/ 53 | 54 | To avoid hitting rate limits on GitHub's API you can go to 55 | [Personal access tokens](https://github.com/settings/tokens) and generate 56 | a token (without any scopes). How can you set: 57 | 58 | ``` 59 | export GITHUB_AUTH_TOKEN=afefdf213840aeb8007310ab05fc33eda51a0652 60 | ``` 61 | 62 | **Environment variables** 63 | 64 | You can put all your environment variables into a `.env` file, like this: 65 | 66 | ``` 67 | GITHUB_AUTH_TOKEN=afefdf213840aeb8007310ab05fc33eda51a0652 68 | DEBUG=1 69 | SQLALCHEMY_DATABASE_URI='postgres:///whatsdeployed' 70 | ``` 71 | 72 | This file will automatically be read when running the Python backend. 73 | 74 | ## Deployment 75 | 76 | **Really basic for now**. 77 | 78 | For the front-end, we check the output of `yarn run build` 79 | as a `build.zip` file. This is generated by running `./bin/build.sh`. 80 | 81 | ## Upgrade dependencies 82 | 83 | To upgrade a dependency, edit `requirements.in` and then run: 84 | 85 | ``` 86 | pip-compile --generate-hashes requirements.in 87 | ``` 88 | 89 | Now you should have a change in `requirements.in` _and_ in `requirements.txt`. 90 | Check in both. 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import json 3 | import time 4 | import os 5 | import random 6 | import warnings 7 | from urllib.parse import parse_qs, urlparse, urlencode 8 | from collections import defaultdict 9 | 10 | import requests 11 | import werkzeug 12 | from requests.exceptions import ReadTimeout 13 | from flask import ( 14 | Flask, 15 | request, 16 | make_response, 17 | jsonify, 18 | send_file, 19 | abort, 20 | redirect, 21 | send_from_directory, 22 | ) 23 | from flask.views import MethodView 24 | from flask_sqlalchemy import SQLAlchemy 25 | from decouple import config 26 | import rollbar 27 | 28 | 29 | DEBUG = config("DEBUG", default=False) 30 | GITHUB_REQUEST_TIMEOUT = config("GITHUB_REQUEST_TIMEOUT", default=10) 31 | GITHUB_REQUEST_HEADERS = { 32 | "User-Agent": config( 33 | "REQUESTS_USER_AGENT", default="whatsdeployed (https://whatsdeployed.io)" 34 | ) 35 | } 36 | GITHUB_AUTH_TOKEN = config("GITHUB_AUTH_TOKEN", default=None) 37 | if GITHUB_AUTH_TOKEN: 38 | GITHUB_REQUEST_HEADERS["Authorization"] = "token {}".format(GITHUB_AUTH_TOKEN) 39 | else: 40 | warnings.warn("GITHUB_AUTH_TOKEN is NOT available. Worry about rate limits.") 41 | 42 | ROLLBAR_ACCESS_TOKEN = config("ROLLBAR_ACCESS_TOKEN", default=None) 43 | if ROLLBAR_ACCESS_TOKEN: 44 | rollbar.init(ROLLBAR_ACCESS_TOKEN) 45 | rollbar.report_message("Rollbar is configured correctly") 46 | print("Rollbar enabled.") 47 | else: 48 | print("Rollbar NOT enabled.") 49 | 50 | # Set static_folder=None to suppress the standard static server 51 | app = Flask(__name__, static_folder=None) 52 | app.config["SQLALCHEMY_DATABASE_URI"] = config( 53 | "SQLALCHEMY_DATABASE_URI", "postgres://localhost/whatsdeployed" 54 | ) 55 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = DEBUG 56 | db = SQLAlchemy(app) 57 | 58 | 59 | class Shortlink(db.Model): 60 | id = db.Column(db.Integer, primary_key=True) 61 | link = db.Column(db.String(80), unique=True) 62 | owner = db.Column(db.String(200)) 63 | repo = db.Column(db.String(200)) 64 | revisions = db.Column(db.Text) 65 | 66 | def __init__(self, link, owner, repo, revisions): 67 | self.link = link 68 | self.owner = owner 69 | self.repo = repo 70 | assert isinstance(revisions, list), type(revisions) 71 | self.revisions = json.dumps(revisions) 72 | 73 | def __repr__(self): 74 | return "" % self.link 75 | 76 | 77 | def extract_sha(content): 78 | content = content.strip() 79 | 80 | if content.startswith("{") and content.endswith("}"): 81 | # If it starts and ends with curly braces, it just might be a json 82 | # object 83 | try: 84 | data = json.loads(content) 85 | 86 | # This is where it's at in Dockerflow-supported sites 87 | if "commit" in data: 88 | return data["commit"] 89 | 90 | except json.decoder.JSONDecodeError: 91 | pass 92 | 93 | if 7 <= len(content) <= 40: 94 | return content 95 | 96 | 97 | class ShasView(MethodView): 98 | def post(self): 99 | deployments = [] 100 | environment = request.json 101 | 102 | base_url = "https://api.github.com/repos/{owner}/{repo}".format( 103 | repo=environment["repo"], owner=environment["owner"] 104 | ) 105 | tags_url = base_url + ("/tags?sort=created&direction=desc") 106 | 107 | tags = {} 108 | while True: 109 | try: 110 | r = requests.get( 111 | tags_url, 112 | headers=GITHUB_REQUEST_HEADERS, 113 | timeout=GITHUB_REQUEST_TIMEOUT, 114 | ) 115 | r.raise_for_status() 116 | for tag in r.json(): 117 | tags[tag["commit"]["sha"]] = tag["name"] 118 | try: 119 | tags_url = r.links["next"]["url"] 120 | except KeyError: 121 | break 122 | except ReadTimeout: 123 | break 124 | 125 | for each in environment["deployments"]: 126 | name = each["name"] 127 | url = each["url"] 128 | 129 | # Skip empty urls 130 | if not url: 131 | continue 132 | 133 | # Fetch the sha and balk if it doesn't exist 134 | try: 135 | response = self.fetch_content(url) 136 | if response.status_code != 200: 137 | return make_response( 138 | jsonify( 139 | { 140 | "error": "{} trying to load {}".format( 141 | response.status_code, url 142 | ) 143 | } 144 | ) 145 | ) 146 | except ReadTimeout: 147 | return make_response( 148 | jsonify({"error": "Timeout error trying to load {}".format(url)}) 149 | ) 150 | content = response.text.strip() 151 | sha = extract_sha(content) 152 | if not sha: 153 | # doesn't appear to be a git sha 154 | error = "Doesn't look like a sha\n (%s) on %s" % (content, each["url"]) 155 | return make_response(jsonify({"error": error})) 156 | 157 | deployments.append({"name": name, "sha": sha, "bugs": [], "url": url}) 158 | 159 | response = make_response(jsonify({"deployments": deployments, "tags": tags})) 160 | return response 161 | 162 | def fetch_content(self, url): 163 | if "?" in url: 164 | url += "&" 165 | else: 166 | url += "?" 167 | url += "cachescramble=%s" % time.time() 168 | return requests.get( 169 | url, headers=GITHUB_REQUEST_HEADERS, timeout=GITHUB_REQUEST_TIMEOUT 170 | ) 171 | 172 | 173 | class CulpritsView(MethodView): 174 | def post(self): 175 | groups = [] 176 | deployments = request.json["deployments"] 177 | base_url = "https://api.github.com/repos/{owner}/{repo}".format( 178 | repo=request.json["repo"], owner=request.json["owner"] 179 | ) 180 | pulls_url = base_url + ("/pulls?sort=created&state=closed&direction=desc") 181 | _looked_up = [] 182 | for deployment in deployments: 183 | name = deployment["name"] 184 | sha = deployment["sha"] 185 | if sha in _looked_up: 186 | # If you have, for example Stage on the exact same 187 | # sha as Prod, then there's no going looking it up 188 | # twice. 189 | continue 190 | 191 | users = [] 192 | links = [] 193 | try: 194 | r = requests.get( 195 | pulls_url, 196 | headers=GITHUB_REQUEST_HEADERS, 197 | timeout=GITHUB_REQUEST_TIMEOUT, 198 | ) 199 | r.raise_for_status() 200 | except ReadTimeout: 201 | return make_response( 202 | jsonify( 203 | {"error": "Timeout error trying to load {}".format(pulls_url)} 204 | ) 205 | ) 206 | 207 | for pr in r.json(): 208 | if pr["merge_commit_sha"] == sha: 209 | links.append(pr["_links"]["html"]["href"]) 210 | author = pr["user"] 211 | users.append(("Author", author)) 212 | committer = pr.get("committer") 213 | if committer and committer != author: 214 | users.append(("Committer", committer)) 215 | # let's also dig into what other people participated 216 | for assignee in pr["assignees"]: 217 | users.append(("Assignee", assignee)) 218 | # Other people who commented on the PR 219 | issues_url = base_url + ( 220 | "/issues/{number}/comments".format(number=pr["number"]) 221 | ) 222 | r = requests.get( 223 | issues_url, 224 | headers=GITHUB_REQUEST_HEADERS, 225 | timeout=GITHUB_REQUEST_TIMEOUT, 226 | ) 227 | r.raise_for_status() 228 | for comment in r.json(): 229 | try: 230 | user = comment["user"] 231 | users.append(("Commenter", user)) 232 | except TypeError: 233 | print("COMMENT") 234 | print(comment) 235 | break 236 | 237 | commits_url = base_url + ("/commits/{sha}".format(sha=sha)) 238 | r = requests.get( 239 | commits_url, 240 | headers=GITHUB_REQUEST_HEADERS, 241 | timeout=GITHUB_REQUEST_TIMEOUT, 242 | ) 243 | r.raise_for_status() 244 | commit = r.json() 245 | 246 | author = commit["author"] 247 | if ("Author", author) not in users: 248 | users.append(("Author", author)) 249 | committer = commit.get("committer") 250 | if committer: 251 | if committer["login"] == "web-flow": 252 | # Then the author pressed the green button and let 253 | # GitHub merge it. 254 | # Change the label of the author to also be the committer 255 | users.append(("Committer", author)) 256 | elif committer != author: 257 | users.append(("Committer", committer)) 258 | # Now merge the labels for user 259 | labels = defaultdict(list) 260 | for label, user in users: 261 | if label not in labels[user["login"]]: 262 | labels[user["login"]].append(label) 263 | labels_map = {} 264 | for login, labels in labels.items(): 265 | labels_map[login] = " & ".join(labels) 266 | unique_users = [] 267 | _logins = set() 268 | for _, user in users: 269 | if user["login"] not in _logins: 270 | _logins.add(user["login"]) 271 | unique_users.append((labels_map[user["login"]], user)) 272 | groups.append({"name": name, "users": unique_users, "links": links}) 273 | _looked_up.append(sha) 274 | 275 | response = make_response(jsonify({"culprits": groups})) 276 | return response 277 | 278 | 279 | class ShortenView(MethodView): 280 | def post(self): 281 | url = request.json["url"] 282 | parsed = parse_qs(urlparse(url).query) 283 | owner = parsed["owner"][0] 284 | repo = parsed["repo"][0] 285 | revisions = [] 286 | for i, name in enumerate(parsed["name[]"]): 287 | revisions.append((name, parsed["url[]"][i])) 288 | # Does it already exist?? 289 | shortlink = Shortlink.query.filter_by( 290 | owner=owner, repo=repo, revisions=json.dumps(revisions) 291 | ).first() 292 | 293 | if shortlink is not None: 294 | link = shortlink.link 295 | else: 296 | 297 | def new_random_link(length): 298 | pool = "abcdefghijklmnopqrstuvwxyz0123456789" 299 | pool += pool.upper() 300 | new = "" 301 | while len(new) < length: 302 | new += random.choice(list(pool)) 303 | return new 304 | 305 | link = new_random_link(3) 306 | while Shortlink.query.filter_by(link=link).count(): 307 | link = new_random_link(3) 308 | shortlink = Shortlink(link, owner, repo, revisions) 309 | db.session.add(shortlink) 310 | db.session.commit() 311 | 312 | new_url = "/s-{}".format(link) 313 | return make_response(jsonify({"url": new_url})) 314 | 315 | 316 | class LengthenView(MethodView): 317 | def get(self, link): 318 | shortlink = Shortlink.query.filter_by(link=link).first() 319 | if shortlink is None: 320 | abort(404) 321 | response = {"repo": shortlink.repo, "owner": shortlink.owner, "deployments": []} 322 | for k, v in json.loads(shortlink.revisions): 323 | response["deployments"].append({"name": k, "url": v}) 324 | return make_response(jsonify(response)) 325 | 326 | 327 | class ShortenedView(MethodView): 328 | def get(self): 329 | urls = request.args.get("urls") 330 | if not urls: 331 | abort(400) 332 | ids = [x.replace("/s-", "") for x in urls.split(",") if x.startswith("/s-")] 333 | environments = [] 334 | shortlinks = Shortlink.query.filter(Shortlink.link.in_(ids)).all() 335 | for shortlink in shortlinks: 336 | environments.append( 337 | { 338 | "shortlink": shortlink.link, 339 | "owner": shortlink.owner, 340 | "repo": shortlink.repo, 341 | "revisions": json.loads(shortlink.revisions), 342 | } 343 | ) 344 | 345 | return make_response(jsonify({"environments": environments})) 346 | 347 | 348 | class ShortlinkRedirectView(MethodView): 349 | def get(self, link): 350 | shortlink = Shortlink.query.filter_by(link=link).first() 351 | if shortlink is None: 352 | abort(404) 353 | qs = { 354 | "repo": shortlink.repo, 355 | "owner": shortlink.owner, 356 | "name[]": [], 357 | "url[]": [], 358 | } 359 | for k, v in json.loads(shortlink.revisions): 360 | qs["name[]"].append(k) 361 | qs["url[]"].append(v) 362 | return redirect("/?" + urlencode(qs, True)) 363 | 364 | 365 | class GitHubAPI(MethodView): 366 | """The client needs to make queries to the GitHub API but a shortcoming 367 | is that it's impossible to include an auth token. And if you can't 368 | do that clients are likely to hit rate limits.""" 369 | 370 | def get(self, thing): 371 | url = "https://api.github.com" 372 | if thing == "commits": 373 | copied = dict(request.args) 374 | owner = request.args.get("owner") 375 | repo = request.args.get("repo") 376 | if not owner: 377 | abort(400, "No 'owner'") 378 | if not repo: 379 | abort(400, "No 'repo'") 380 | 381 | url += "/repos/{}/{}/commits".format(owner, repo) 382 | copied.pop("owner") 383 | copied.pop("repo") 384 | if copied: 385 | url += "?" + urlencode(copied, True) 386 | response = requests.get( 387 | url, headers=GITHUB_REQUEST_HEADERS, timeout=GITHUB_REQUEST_TIMEOUT 388 | ) 389 | if response.status_code == 200: 390 | return make_response(jsonify(response.json())) 391 | else: 392 | abort(response.status_code, response.content) 393 | else: 394 | abort(400) 395 | 396 | 397 | class HealthCheckView(MethodView): 398 | """A dumb but comprehensive health check""" 399 | 400 | def get(self): 401 | # Can't really test anything because it might empty on first ever load. 402 | Shortlink.query.count() 403 | 404 | # Commented out for now because it's doing this healthcheck too often. 405 | # # If you attempt to include Authorization headers, even on an endpoint 406 | # # that doesn't need it, it will 401 if the auth token is wrong. 407 | # response = requests.get( 408 | # "https://api.github.com/", 409 | # headers=GITHUB_REQUEST_HEADERS, 410 | # timeout=GITHUB_REQUEST_TIMEOUT, 411 | # ) 412 | # response.raise_for_status() 413 | return "OK\n" 414 | 415 | 416 | app.add_url_rule("/shas", view_func=ShasView.as_view("shas")) 417 | app.add_url_rule("/culprits", view_func=CulpritsView.as_view("culprits")) 418 | app.add_url_rule("/shortenit", view_func=ShortenView.as_view("shortenit")) 419 | app.add_url_rule( 420 | "/lengthenit/", view_func=LengthenView.as_view("lengthenit") 421 | ) 422 | app.add_url_rule("/shortened", view_func=ShortenedView.as_view("shortened")) 423 | app.add_url_rule("/githubapi/", view_func=GitHubAPI.as_view("githubapi")) 424 | app.add_url_rule( 425 | "/s-", view_func=ShortlinkRedirectView.as_view("shortlink") 426 | ) 427 | app.add_url_rule("/__healthcheck__", view_func=HealthCheckView.as_view("healthcheck")) 428 | 429 | 430 | @app.route("/", defaults={"path": "index.html"}) 431 | @app.route("/") 432 | def index_html(path): 433 | # try to serve static files out of ./build/ 434 | try: 435 | return send_from_directory("build", path) 436 | except werkzeug.exceptions.NotFound: 437 | # try to serve index.html in the requested path 438 | if path.endswith("/"): 439 | return send_from_directory("build", path + "index.html") 440 | else: 441 | # fall back to index.html 442 | return send_file("build/index.html") 443 | 444 | 445 | if __name__ == "__main__": 446 | db.create_all() 447 | 448 | app.debug = DEBUG 449 | port = int(os.environ.get("PORT", 5000)) 450 | host = os.environ.get("HOST", "0.0.0.0") 451 | app.run(host=host, port=port) 452 | -------------------------------------------------------------------------------- /bin/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | fd -t f -I -H '\~$' | xargs rm -f 6 | 7 | INLINE_RUNTIME_CHUNK=false yarn run build 8 | 9 | zopfli build/static/**/*.css 10 | zopfli build/static/**/*.js 11 | brotli build/static/**/*.css 12 | brotli build/static/**/*.js 13 | 14 | ./bin/update_version.js > build/version.json 15 | ./bin/insert_version.js build/version.json build/index.html 16 | 17 | rm -fr build.zip && apack build.zip build 18 | -------------------------------------------------------------------------------- /bin/download-latest-release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import subprocess 4 | from urllib.parse import urlparse 5 | 6 | import click 7 | import requests 8 | from decouple import config 9 | 10 | 11 | REPO_URL = config( 12 | "REPO_URL", default="https://api.github.com/repos/peterbe/whatsdeployed" 13 | ) 14 | URL = REPO_URL + "/releases" 15 | 16 | 17 | def _check_output(*args, **kwargs): 18 | return subprocess.check_output(*args, **kwargs).decode("utf-8").strip() 19 | 20 | 21 | def _download(url): 22 | r = requests.get(url) 23 | r.raise_for_status() 24 | if "application/json" in r.headers["content-type"]: 25 | return r.json() 26 | return r 27 | 28 | 29 | @click.command() 30 | @click.option("-v", "--verbose", is_flag=True) 31 | @click.option("-t", "--tag-name", help="tag name if not the current") 32 | @click.option("-d", "--destination", help="place to download the zip file (default ./)") 33 | def cli(tag_name=None, verbose=False, destination=None): 34 | destination = destination or "." 35 | if not tag_name: 36 | tag_name = _check_output( 37 | [ 38 | "git", 39 | "for-each-ref", 40 | "--sort=-taggerdate", 41 | "--count=1", 42 | "--format", 43 | "%(tag)", 44 | "refs/tags", 45 | ] 46 | ) 47 | assert tag_name 48 | 49 | for release in _download(URL): 50 | if release["tag_name"] == tag_name: 51 | for asset in release["assets"]: 52 | if asset["content_type"] == "application/zip": 53 | url = asset["browser_download_url"] 54 | print("Downloading", url) 55 | fn = os.path.basename(urlparse(url).path) 56 | fp = os.path.join(destination, fn) 57 | with open(fp, "wb") as f: 58 | f.write(_download(url).content) 59 | print("Downloaded", fp, os.stat(fp).st_size, "bytes") 60 | break 61 | else: 62 | error_out("No application/zip asset found") 63 | break 64 | else: 65 | error_out("No tag name called {!r}".format(tag_name)) 66 | 67 | 68 | def error_out(msg, raise_abort=True): 69 | click.echo(click.style(msg, fg="red")) 70 | if raise_abort: 71 | raise click.Abort 72 | 73 | 74 | if __name__ == "__main__": 75 | cli() 76 | -------------------------------------------------------------------------------- /bin/insert_version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs'); 3 | 4 | const versionFile = process.argv[2]; 5 | if (!versionFile) throw new Error('missing file argument'); 6 | const versionString = fs.readFileSync(versionFile, 'utf8'); 7 | const data = JSON.parse(versionString); 8 | 9 | const htmlFile = process.argv[3]; 10 | if (!htmlFile) throw new Error('missing file argument'); 11 | let html = fs.readFileSync(htmlFile, 'utf8'); 12 | 13 | let tag = '
{ 16 | if (keeps.includes(key)) { 17 | tag += `data-${key}="${value}" `; 18 | } 19 | }); 20 | tag = tag.trim() + '/>'; 21 | 22 | html = html.replace(/
]+>/, tag); 23 | fs.writeFileSync(htmlFile, html, 'utf8'); 24 | -------------------------------------------------------------------------------- /bin/ship-release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import datetime 3 | import shutil 4 | import subprocess 5 | 6 | import requests 7 | from decouple import config 8 | 9 | 10 | GITHUB_AUTH_TOKEN = config("GITHUB_AUTH_TOKEN") 11 | REPO_URL = config( 12 | "REPO_URL", default="https://api.github.com/repos/peterbe/whatsdeployed" 13 | ) 14 | URL = REPO_URL + "/releases" 15 | 16 | 17 | def _check_output(*args, **kwargs): 18 | return subprocess.check_output(*args, **kwargs).decode("utf-8").strip() 19 | 20 | 21 | def run(*args): 22 | last_tag = _check_output( 23 | [ 24 | "git", 25 | "for-each-ref", 26 | "--sort=-taggerdate", 27 | "--count=1", 28 | "--format", 29 | "%(tag)|%(contents:subject)", 30 | "refs/tags", 31 | ] 32 | ) 33 | if not last_tag: 34 | print("You don't have any previous tags in this git repo.") 35 | return 1 36 | 37 | last_tag, last_tag_message = last_tag.split("|", 1) 38 | # print("LAST_TAG:", last_tag) 39 | # print("last_tag_message:", last_tag_message) 40 | columns, _ = shutil.get_terminal_size() 41 | 42 | commits_since = _check_output(f"git log {last_tag}..HEAD --oneline".split()) 43 | print("Commits since last tag: ".ljust(columns, "_")) 44 | commits_since_count = 0 45 | for commit in commits_since.splitlines(): 46 | print("\t", commit) 47 | commits_since_count += 1 48 | 49 | if not commits_since_count: 50 | print("There has not been any commits since the last tag was made.") 51 | return 2 52 | 53 | print("-" * columns) 54 | 55 | # Next, come up with the next tag name. 56 | # Normally it's today's date in ISO format with dots. 57 | tag_name = datetime.datetime.utcnow().strftime("%Y.%m.%d") 58 | # But is it taken, if so how many times has it been taken before? 59 | existing_tags = _check_output( 60 | # ["git", "tag", "-l", "{}*".format(tag_name)] 61 | ["git", "tag", "-l"] 62 | ).splitlines() 63 | if tag_name in existing_tags: 64 | count_starts = len([x for x in existing_tags if x.startswith(tag_name)]) 65 | tag_name += "-{}".format(count_starts + 1) 66 | 67 | tag_name = input(f"Tag name [{tag_name}]? ").strip() or tag_name 68 | if tag_name not in existing_tags: 69 | # Now we need to figure out what's been 70 | message = input("Tag message? (Optional, else all commit messages) ") 71 | if not message: 72 | message = commits_since 73 | 74 | # Now we can create the tag 75 | subprocess.check_call(["git", "tag", "-s", "-a", tag_name, "-m", message]) 76 | 77 | # Let's push this now 78 | subprocess.check_call("git push origin master --tags".split()) 79 | else: 80 | message = last_tag_message 81 | 82 | name = tag_name # for now 83 | name = f"Static builds for {tag_name}" 84 | 85 | assert GITHUB_AUTH_TOKEN 86 | headers = {"Authorization": f"token {GITHUB_AUTH_TOKEN}"} 87 | 88 | response = requests.get(URL, headers=headers) 89 | response.raise_for_status() 90 | existing_releases = response.json() 91 | old_releases = {x["tag_name"]: x for x in existing_releases} 92 | if tag_name not in old_releases: 93 | release_data = {"tag_name": tag_name, "name": name, "body": message} 94 | response = requests.post(URL, headers=headers, json=release_data) 95 | response.raise_for_status() 96 | upload_url = response.json()["upload_url"] 97 | else: 98 | upload_url = old_releases[tag_name]["upload_url"] 99 | 100 | upload_url = upload_url.replace("{?name,label}", "?name=build.zip") 101 | 102 | headers.update( 103 | {"Accept": "application/vnd.github.v3+json", "Content-Type": "application/zip"} 104 | ) 105 | with open("build.zip", "rb") as f: 106 | payload = f.read() 107 | response = requests.post(upload_url, headers=headers, data=payload) 108 | response.raise_for_status() 109 | print("💥Done!", response.json()["browser_download_url"]) 110 | 111 | 112 | if __name__ == "__main__": 113 | import sys 114 | 115 | sys.exit(run(*sys.argv[1:])) 116 | -------------------------------------------------------------------------------- /bin/update_version.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs'); 3 | const spawn = require('child_process').spawnSync; 4 | 5 | const package = require('../package.json'); 6 | const name = package.name; 7 | 8 | const spawnStr = (cmd, ...args) => { 9 | const spawned = spawn(cmd, ...args); 10 | return spawned.stdout.toString().trim(); 11 | }; 12 | const version = spawnStr('git', ['describe', '--always', '--tag']); 13 | 14 | const logRaw = spawnStr('git', ['log', "--pretty=format:'%H--%cI'", '-n', '1']); 15 | 16 | const log = logRaw.slice(1, logRaw.length - 1); 17 | const commit = log.split('--')[0]; 18 | const date = log.split('--')[1]; 19 | 20 | console.log( 21 | JSON.stringify( 22 | { 23 | name, 24 | version, 25 | commit, 26 | date, 27 | }, 28 | undefined, 29 | 2 30 | ) 31 | ); 32 | -------------------------------------------------------------------------------- /culprits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/culprits.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whatsdeployed", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "bootstrap": "4.6.0", 7 | "classnames": "2.3.1", 8 | "ky": "0.25.0", 9 | "react": "17.0.2", 10 | "react-copy-to-clipboard": "5.0.3", 11 | "react-dom": "17.0.2", 12 | "react-router-dom": "5.2.0", 13 | "react-scripts": "4.0.3", 14 | "react-timeago": "6.1.1", 15 | "reactstrap": "8.10.0" 16 | }, 17 | "devDependencies": { 18 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11", 19 | "prettier": "3.0.3", 20 | "source-map-explorer": "2.5.3" 21 | }, 22 | "scripts": { 23 | "analyze": "source-map-explorer build/static/js/main.*", 24 | "start": "react-scripts --openssl-legacy-provider start", 25 | "build": "react-scripts --openssl-legacy-provider build", 26 | "test": "react-scripts test", 27 | "test:ci": "CI=true yarn test", 28 | "prettier:check": "prettier --check src", 29 | "prettier:format": "prettier --write src" 30 | }, 31 | "proxy": "http://localhost:5000", 32 | "eslintConfig": { 33 | "extends": "react-app" 34 | }, 35 | "browserslist": [ 36 | ">0.2%", 37 | "not dead", 38 | "not ie <= 11", 39 | "not op_mini all" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | What's Deployed? 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 |
24 | 25 | 31 |
32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Whatsdeployed", 3 | "icons": [ 4 | { 5 | "src": "\/static\/android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image\/png" 8 | }, 9 | { 10 | "src": "\/static\/android-chrome-512x512.png", 11 | "sizes": "512x512", 12 | "type": "image\/png" 13 | } 14 | ], 15 | "theme_color": "#ffffff", 16 | "display": "standalone" 17 | } 18 | -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | Flask==2.0.1 2 | psycopg2-binary==2.9.7 3 | Flask-SQLAlchemy==2.5.1 4 | cryptography==3.4.7 5 | requests==2.26.0 6 | python-decouple==3.8 7 | python-dotenv==1.0.0 8 | rollbar==0.16.3 9 | 10 | black==23.9.1 11 | flake8==5.0.4 12 | pip-tools==7.3.0 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.8 3 | # by the following command: 4 | # 5 | # pip-compile --generate-hashes requirements.in 6 | # 7 | black==23.9.1 \ 8 | --hash=sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f \ 9 | --hash=sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7 \ 10 | --hash=sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100 \ 11 | --hash=sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573 \ 12 | --hash=sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d \ 13 | --hash=sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f \ 14 | --hash=sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9 \ 15 | --hash=sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300 \ 16 | --hash=sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948 \ 17 | --hash=sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325 \ 18 | --hash=sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9 \ 19 | --hash=sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71 \ 20 | --hash=sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186 \ 21 | --hash=sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f \ 22 | --hash=sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe \ 23 | --hash=sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855 \ 24 | --hash=sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80 \ 25 | --hash=sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393 \ 26 | --hash=sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c \ 27 | --hash=sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204 \ 28 | --hash=sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377 \ 29 | --hash=sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301 30 | # via -r requirements.in 31 | build==1.0.3 \ 32 | --hash=sha256:538aab1b64f9828977f84bc63ae570b060a8ed1be419e7870b8b4fc5e6ea553b \ 33 | --hash=sha256:589bf99a67df7c9cf07ec0ac0e5e2ea5d4b37ac63301c4986d1acb126aa83f8f 34 | # via pip-tools 35 | certifi==2021.5.30 \ 36 | --hash=sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee \ 37 | --hash=sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8 38 | # via requests 39 | cffi==1.15.1 \ 40 | --hash=sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5 \ 41 | --hash=sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef \ 42 | --hash=sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104 \ 43 | --hash=sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426 \ 44 | --hash=sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405 \ 45 | --hash=sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375 \ 46 | --hash=sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a \ 47 | --hash=sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e \ 48 | --hash=sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc \ 49 | --hash=sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf \ 50 | --hash=sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185 \ 51 | --hash=sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497 \ 52 | --hash=sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3 \ 53 | --hash=sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35 \ 54 | --hash=sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c \ 55 | --hash=sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83 \ 56 | --hash=sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21 \ 57 | --hash=sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca \ 58 | --hash=sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984 \ 59 | --hash=sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac \ 60 | --hash=sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd \ 61 | --hash=sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee \ 62 | --hash=sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a \ 63 | --hash=sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2 \ 64 | --hash=sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192 \ 65 | --hash=sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7 \ 66 | --hash=sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585 \ 67 | --hash=sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f \ 68 | --hash=sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e \ 69 | --hash=sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27 \ 70 | --hash=sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b \ 71 | --hash=sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e \ 72 | --hash=sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e \ 73 | --hash=sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d \ 74 | --hash=sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c \ 75 | --hash=sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415 \ 76 | --hash=sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82 \ 77 | --hash=sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02 \ 78 | --hash=sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314 \ 79 | --hash=sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325 \ 80 | --hash=sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c \ 81 | --hash=sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3 \ 82 | --hash=sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914 \ 83 | --hash=sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045 \ 84 | --hash=sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d \ 85 | --hash=sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9 \ 86 | --hash=sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5 \ 87 | --hash=sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2 \ 88 | --hash=sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c \ 89 | --hash=sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3 \ 90 | --hash=sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2 \ 91 | --hash=sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8 \ 92 | --hash=sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d \ 93 | --hash=sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d \ 94 | --hash=sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9 \ 95 | --hash=sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162 \ 96 | --hash=sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76 \ 97 | --hash=sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4 \ 98 | --hash=sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e \ 99 | --hash=sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9 \ 100 | --hash=sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6 \ 101 | --hash=sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b \ 102 | --hash=sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01 \ 103 | --hash=sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0 104 | # via cryptography 105 | charset-normalizer==2.0.3 \ 106 | --hash=sha256:88fce3fa5b1a84fdcb3f603d889f723d1dd89b26059d0123ca435570e848d5e1 \ 107 | --hash=sha256:c46c3ace2d744cfbdebceaa3c19ae691f53ae621b39fd7570f59d14fb7f2fd12 108 | # via requests 109 | click==8.0.1 \ 110 | --hash=sha256:8c04c11192119b1ef78ea049e0a6f0463e4c48ef00a30160c704337586f3ad7a \ 111 | --hash=sha256:fba402a4a47334742d782209a7c79bc448911afe1149d07bdabdf480b3e2f4b6 112 | # via 113 | # black 114 | # flask 115 | # pip-tools 116 | cryptography==3.4.7 \ 117 | --hash=sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d \ 118 | --hash=sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959 \ 119 | --hash=sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6 \ 120 | --hash=sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873 \ 121 | --hash=sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2 \ 122 | --hash=sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713 \ 123 | --hash=sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1 \ 124 | --hash=sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177 \ 125 | --hash=sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250 \ 126 | --hash=sha256:b01fd6f2737816cb1e08ed4807ae194404790eac7ad030b34f2ce72b332f5586 \ 127 | --hash=sha256:bf40af59ca2465b24e54f671b2de2c59257ddc4f7e5706dbd6930e26823668d3 \ 128 | --hash=sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca \ 129 | --hash=sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d \ 130 | --hash=sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9 131 | # via -r requirements.in 132 | flake8==5.0.4 \ 133 | --hash=sha256:6fbe320aad8d6b95cec8b8e47bc933004678dc63095be98528b7bdd2a9f510db \ 134 | --hash=sha256:7a1cf6b73744f5806ab95e526f6f0d8c01c66d7bbe349562d22dfca20610b248 135 | # via -r requirements.in 136 | flask==2.0.1 \ 137 | --hash=sha256:1c4c257b1892aec1398784c63791cbaa43062f1f7aeb555c4da961b20ee68f55 \ 138 | --hash=sha256:a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9 139 | # via 140 | # -r requirements.in 141 | # flask-sqlalchemy 142 | flask-sqlalchemy==2.5.1 \ 143 | --hash=sha256:2bda44b43e7cacb15d4e05ff3cc1f8bc97936cc464623424102bfc2c35e95912 \ 144 | --hash=sha256:f12c3d4cc5cc7fdcc148b9527ea05671718c3ea45d50c7e732cceb33f574b390 145 | # via -r requirements.in 146 | greenlet==1.1.0 \ 147 | --hash=sha256:03f28a5ea20201e70ab70518d151116ce939b412961c33827519ce620957d44c \ 148 | --hash=sha256:06d7ac89e6094a0a8f8dc46aa61898e9e1aec79b0f8b47b2400dd51a44dbc832 \ 149 | --hash=sha256:06ecb43b04480e6bafc45cb1b4b67c785e183ce12c079473359e04a709333b08 \ 150 | --hash=sha256:096cb0217d1505826ba3d723e8981096f2622cde1eb91af9ed89a17c10aa1f3e \ 151 | --hash=sha256:0c557c809eeee215b87e8a7cbfb2d783fb5598a78342c29ade561440abae7d22 \ 152 | --hash=sha256:0de64d419b1cb1bfd4ea544bedea4b535ef3ae1e150b0f2609da14bbf48a4a5f \ 153 | --hash=sha256:14927b15c953f8f2d2a8dffa224aa78d7759ef95284d4c39e1745cf36e8cdd2c \ 154 | --hash=sha256:16183fa53bc1a037c38d75fdc59d6208181fa28024a12a7f64bb0884434c91ea \ 155 | --hash=sha256:206295d270f702bc27dbdbd7651e8ebe42d319139e0d90217b2074309a200da8 \ 156 | --hash=sha256:22002259e5b7828b05600a762579fa2f8b33373ad95a0ee57b4d6109d0e589ad \ 157 | --hash=sha256:2325123ff3a8ecc10ca76f062445efef13b6cf5a23389e2df3c02a4a527b89bc \ 158 | --hash=sha256:258f9612aba0d06785143ee1cbf2d7361801c95489c0bd10c69d163ec5254a16 \ 159 | --hash=sha256:3096286a6072553b5dbd5efbefc22297e9d06a05ac14ba017233fedaed7584a8 \ 160 | --hash=sha256:3d13da093d44dee7535b91049e44dd2b5540c2a0e15df168404d3dd2626e0ec5 \ 161 | --hash=sha256:408071b64e52192869129a205e5b463abda36eff0cebb19d6e63369440e4dc99 \ 162 | --hash=sha256:598bcfd841e0b1d88e32e6a5ea48348a2c726461b05ff057c1b8692be9443c6e \ 163 | --hash=sha256:5d928e2e3c3906e0a29b43dc26d9b3d6e36921eee276786c4e7ad9ff5665c78a \ 164 | --hash=sha256:5f75e7f237428755d00e7460239a2482fa7e3970db56c8935bd60da3f0733e56 \ 165 | --hash=sha256:60848099b76467ef09b62b0f4512e7e6f0a2c977357a036de602b653667f5f4c \ 166 | --hash=sha256:6b1d08f2e7f2048d77343279c4d4faa7aef168b3e36039cba1917fffb781a8ed \ 167 | --hash=sha256:70bd1bb271e9429e2793902dfd194b653221904a07cbf207c3139e2672d17959 \ 168 | --hash=sha256:76ed710b4e953fc31c663b079d317c18f40235ba2e3d55f70ff80794f7b57922 \ 169 | --hash=sha256:7920e3eccd26b7f4c661b746002f5ec5f0928076bd738d38d894bb359ce51927 \ 170 | --hash=sha256:7db68f15486d412b8e2cfcd584bf3b3a000911d25779d081cbbae76d71bd1a7e \ 171 | --hash=sha256:8833e27949ea32d27f7e96930fa29404dd4f2feb13cce483daf52e8842ec246a \ 172 | --hash=sha256:944fbdd540712d5377a8795c840a97ff71e7f3221d3fddc98769a15a87b36131 \ 173 | --hash=sha256:9a6b035aa2c5fcf3dbbf0e3a8a5bc75286fc2d4e6f9cfa738788b433ec894919 \ 174 | --hash=sha256:9bdcff4b9051fb1aa4bba4fceff6a5f770c6be436408efd99b76fc827f2a9319 \ 175 | --hash=sha256:a9017ff5fc2522e45562882ff481128631bf35da444775bc2776ac5c61d8bcae \ 176 | --hash=sha256:aa4230234d02e6f32f189fd40b59d5a968fe77e80f59c9c933384fe8ba535535 \ 177 | --hash=sha256:ad80bb338cf9f8129c049837a42a43451fc7c8b57ad56f8e6d32e7697b115505 \ 178 | --hash=sha256:adb94a28225005890d4cf73648b5131e885c7b4b17bc762779f061844aabcc11 \ 179 | --hash=sha256:b3090631fecdf7e983d183d0fad7ea72cfb12fa9212461a9b708ff7907ffff47 \ 180 | --hash=sha256:b33b51ab057f8a20b497ffafdb1e79256db0c03ef4f5e3d52e7497200e11f821 \ 181 | --hash=sha256:b97c9a144bbeec7039cca44df117efcbeed7209543f5695201cacf05ba3b5857 \ 182 | --hash=sha256:be13a18cec649ebaab835dff269e914679ef329204704869f2f167b2c163a9da \ 183 | --hash=sha256:be9768e56f92d1d7cd94185bab5856f3c5589a50d221c166cc2ad5eb134bd1dc \ 184 | --hash=sha256:c1580087ab493c6b43e66f2bdd165d9e3c1e86ef83f6c2c44a29f2869d2c5bd5 \ 185 | --hash=sha256:c35872b2916ab5a240d52a94314c963476c989814ba9b519bc842e5b61b464bb \ 186 | --hash=sha256:c70c7dd733a4c56838d1f1781e769081a25fade879510c5b5f0df76956abfa05 \ 187 | --hash=sha256:c767458511a59f6f597bfb0032a1c82a52c29ae228c2c0a6865cfeaeaac4c5f5 \ 188 | --hash=sha256:c87df8ae3f01ffb4483c796fe1b15232ce2b219f0b18126948616224d3f658ee \ 189 | --hash=sha256:ca1c4a569232c063615f9e70ff9a1e2fee8c66a6fb5caf0f5e8b21a396deec3e \ 190 | --hash=sha256:cc407b68e0a874e7ece60f6639df46309376882152345508be94da608cc0b831 \ 191 | --hash=sha256:da862b8f7de577bc421323714f63276acb2f759ab8c5e33335509f0b89e06b8f \ 192 | --hash=sha256:dfe7eac0d253915116ed0cd160a15a88981a1d194c1ef151e862a5c7d2f853d3 \ 193 | --hash=sha256:ed1377feed808c9c1139bdb6a61bcbf030c236dd288d6fca71ac26906ab03ba6 \ 194 | --hash=sha256:f42ad188466d946f1b3afc0a9e1a266ac8926461ee0786c06baac6bd71f8a6f3 \ 195 | --hash=sha256:f92731609d6625e1cc26ff5757db4d32b6b810d2a3363b0ff94ff573e5901f6f 196 | # via sqlalchemy 197 | idna==2.10 \ 198 | --hash=sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6 \ 199 | --hash=sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0 200 | # via requests 201 | importlib-metadata==6.8.0 \ 202 | --hash=sha256:3ebb78df84a805d7698245025b975d9d67053cd94c79245ba4b3eb694abe68bb \ 203 | --hash=sha256:dbace7892d8c0c4ac1ad096662232f831d4e64f4c4545bd53016a3e9d4654743 204 | # via build 205 | itsdangerous==2.0.1 \ 206 | --hash=sha256:5174094b9637652bdb841a3029700391451bd092ba3db90600dea710ba28e97c \ 207 | --hash=sha256:9e724d68fc22902a1435351f84c3fb8623f303fffcc566a4cb952df8c572cff0 208 | # via flask 209 | jinja2==3.0.1 \ 210 | --hash=sha256:1f06f2da51e7b56b8f238affdd6b4e2c61e39598a378cc49345bc1bd42a978a4 \ 211 | --hash=sha256:703f484b47a6af502e743c9122595cc812b0271f661722403114f71a79d0f5a4 212 | # via flask 213 | markupsafe==2.0.1 \ 214 | --hash=sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298 \ 215 | --hash=sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64 \ 216 | --hash=sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b \ 217 | --hash=sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567 \ 218 | --hash=sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff \ 219 | --hash=sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74 \ 220 | --hash=sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35 \ 221 | --hash=sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26 \ 222 | --hash=sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7 \ 223 | --hash=sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75 \ 224 | --hash=sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f \ 225 | --hash=sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135 \ 226 | --hash=sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8 \ 227 | --hash=sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a \ 228 | --hash=sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914 \ 229 | --hash=sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18 \ 230 | --hash=sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8 \ 231 | --hash=sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2 \ 232 | --hash=sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d \ 233 | --hash=sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b \ 234 | --hash=sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f \ 235 | --hash=sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb \ 236 | --hash=sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833 \ 237 | --hash=sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415 \ 238 | --hash=sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902 \ 239 | --hash=sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9 \ 240 | --hash=sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d \ 241 | --hash=sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066 \ 242 | --hash=sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f \ 243 | --hash=sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5 \ 244 | --hash=sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94 \ 245 | --hash=sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509 \ 246 | --hash=sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51 \ 247 | --hash=sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872 248 | # via jinja2 249 | mccabe==0.7.0 \ 250 | --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ 251 | --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e 252 | # via flake8 253 | mypy-extensions==1.0.0 \ 254 | --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ 255 | --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 256 | # via black 257 | packaging==23.1 \ 258 | --hash=sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61 \ 259 | --hash=sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f 260 | # via 261 | # black 262 | # build 263 | pathspec==0.11.2 \ 264 | --hash=sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20 \ 265 | --hash=sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3 266 | # via black 267 | pip-tools==7.3.0 \ 268 | --hash=sha256:8717693288720a8c6ebd07149c93ab0be1fced0b5191df9e9decd3263e20d85e \ 269 | --hash=sha256:8e9c99127fe024c025b46a0b2d15c7bd47f18f33226cf7330d35493663fc1d1d 270 | # via -r requirements.in 271 | platformdirs==3.10.0 \ 272 | --hash=sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d \ 273 | --hash=sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d 274 | # via black 275 | psycopg2-binary==2.9.7 \ 276 | --hash=sha256:00d8db270afb76f48a499f7bb8fa70297e66da67288471ca873db88382850bf4 \ 277 | --hash=sha256:024eaeb2a08c9a65cd5f94b31ace1ee3bb3f978cd4d079406aef85169ba01f08 \ 278 | --hash=sha256:094af2e77a1976efd4956a031028774b827029729725e136514aae3cdf49b87b \ 279 | --hash=sha256:1011eeb0c51e5b9ea1016f0f45fa23aca63966a4c0afcf0340ccabe85a9f65bd \ 280 | --hash=sha256:11abdbfc6f7f7dea4a524b5f4117369b0d757725798f1593796be6ece20266cb \ 281 | --hash=sha256:122641b7fab18ef76b18860dd0c772290566b6fb30cc08e923ad73d17461dc63 \ 282 | --hash=sha256:17cc17a70dfb295a240db7f65b6d8153c3d81efb145d76da1e4a096e9c5c0e63 \ 283 | --hash=sha256:18f12632ab516c47c1ac4841a78fddea6508a8284c7cf0f292cb1a523f2e2379 \ 284 | --hash=sha256:1b918f64a51ffe19cd2e230b3240ba481330ce1d4b7875ae67305bd1d37b041c \ 285 | --hash=sha256:1c31c2606ac500dbd26381145684d87730a2fac9a62ebcfbaa2b119f8d6c19f4 \ 286 | --hash=sha256:26484e913d472ecb6b45937ea55ce29c57c662066d222fb0fbdc1fab457f18c5 \ 287 | --hash=sha256:2993ccb2b7e80844d534e55e0f12534c2871952f78e0da33c35e648bf002bbff \ 288 | --hash=sha256:2b04da24cbde33292ad34a40db9832a80ad12de26486ffeda883413c9e1b1d5e \ 289 | --hash=sha256:2dec5a75a3a5d42b120e88e6ed3e3b37b46459202bb8e36cd67591b6e5feebc1 \ 290 | --hash=sha256:2df562bb2e4e00ee064779902d721223cfa9f8f58e7e52318c97d139cf7f012d \ 291 | --hash=sha256:3fbb1184c7e9d28d67671992970718c05af5f77fc88e26fd7136613c4ece1f89 \ 292 | --hash=sha256:42a62ef0e5abb55bf6ffb050eb2b0fcd767261fa3faf943a4267539168807522 \ 293 | --hash=sha256:4ecc15666f16f97709106d87284c136cdc82647e1c3f8392a672616aed3c7151 \ 294 | --hash=sha256:4eec5d36dbcfc076caab61a2114c12094c0b7027d57e9e4387b634e8ab36fd44 \ 295 | --hash=sha256:4fe13712357d802080cfccbf8c6266a3121dc0e27e2144819029095ccf708372 \ 296 | --hash=sha256:51d1b42d44f4ffb93188f9b39e6d1c82aa758fdb8d9de65e1ddfe7a7d250d7ad \ 297 | --hash=sha256:59f7e9109a59dfa31efa022e94a244736ae401526682de504e87bd11ce870c22 \ 298 | --hash=sha256:62cb6de84d7767164a87ca97e22e5e0a134856ebcb08f21b621c6125baf61f16 \ 299 | --hash=sha256:642df77484b2dcaf87d4237792246d8068653f9e0f5c025e2c692fc56b0dda70 \ 300 | --hash=sha256:6822c9c63308d650db201ba22fe6648bd6786ca6d14fdaf273b17e15608d0852 \ 301 | --hash=sha256:692df8763b71d42eb8343f54091368f6f6c9cfc56dc391858cdb3c3ef1e3e584 \ 302 | --hash=sha256:6d92e139ca388ccfe8c04aacc163756e55ba4c623c6ba13d5d1595ed97523e4b \ 303 | --hash=sha256:7952807f95c8eba6a8ccb14e00bf170bb700cafcec3924d565235dffc7dc4ae8 \ 304 | --hash=sha256:7db7b9b701974c96a88997d458b38ccb110eba8f805d4b4f74944aac48639b42 \ 305 | --hash=sha256:81d5dd2dd9ab78d31a451e357315f201d976c131ca7d43870a0e8063b6b7a1ec \ 306 | --hash=sha256:8a136c8aaf6615653450817a7abe0fc01e4ea720ae41dfb2823eccae4b9062a3 \ 307 | --hash=sha256:8a7968fd20bd550431837656872c19575b687f3f6f98120046228e451e4064df \ 308 | --hash=sha256:8c721ee464e45ecf609ff8c0a555018764974114f671815a0a7152aedb9f3343 \ 309 | --hash=sha256:8f309b77a7c716e6ed9891b9b42953c3ff7d533dc548c1e33fddc73d2f5e21f9 \ 310 | --hash=sha256:8f94cb12150d57ea433e3e02aabd072205648e86f1d5a0a692d60242f7809b15 \ 311 | --hash=sha256:95a7a747bdc3b010bb6a980f053233e7610276d55f3ca506afff4ad7749ab58a \ 312 | --hash=sha256:9b0c2b466b2f4d89ccc33784c4ebb1627989bd84a39b79092e560e937a11d4ac \ 313 | --hash=sha256:9dcfd5d37e027ec393a303cc0a216be564b96c80ba532f3d1e0d2b5e5e4b1e6e \ 314 | --hash=sha256:a5ee89587696d808c9a00876065d725d4ae606f5f7853b961cdbc348b0f7c9a1 \ 315 | --hash=sha256:a6a8b575ac45af1eaccbbcdcf710ab984fd50af048fe130672377f78aaff6fc1 \ 316 | --hash=sha256:ac83ab05e25354dad798401babaa6daa9577462136ba215694865394840e31f8 \ 317 | --hash=sha256:ad26d4eeaa0d722b25814cce97335ecf1b707630258f14ac4d2ed3d1d8415265 \ 318 | --hash=sha256:ad5ec10b53cbb57e9a2e77b67e4e4368df56b54d6b00cc86398578f1c635f329 \ 319 | --hash=sha256:c82986635a16fb1fa15cd5436035c88bc65c3d5ced1cfaac7f357ee9e9deddd4 \ 320 | --hash=sha256:ced63c054bdaf0298f62681d5dcae3afe60cbae332390bfb1acf0e23dcd25fc8 \ 321 | --hash=sha256:d0b16e5bb0ab78583f0ed7ab16378a0f8a89a27256bb5560402749dbe8a164d7 \ 322 | --hash=sha256:dbbc3c5d15ed76b0d9db7753c0db40899136ecfe97d50cbde918f630c5eb857a \ 323 | --hash=sha256:ded8e15f7550db9e75c60b3d9fcbc7737fea258a0f10032cdb7edc26c2a671fd \ 324 | --hash=sha256:e02bc4f2966475a7393bd0f098e1165d470d3fa816264054359ed4f10f6914ea \ 325 | --hash=sha256:e5666632ba2b0d9757b38fc17337d84bdf932d38563c5234f5f8c54fd01349c9 \ 326 | --hash=sha256:ea5f8ee87f1eddc818fc04649d952c526db4426d26bab16efbe5a0c52b27d6ab \ 327 | --hash=sha256:eb1c0e682138f9067a58fc3c9a9bf1c83d8e08cfbee380d858e63196466d5c86 \ 328 | --hash=sha256:eb3b8d55924a6058a26db69fb1d3e7e32695ff8b491835ba9f479537e14dcf9f \ 329 | --hash=sha256:ee919b676da28f78f91b464fb3e12238bd7474483352a59c8a16c39dfc59f0c5 \ 330 | --hash=sha256:f02f4a72cc3ab2565c6d9720f0343cb840fb2dc01a2e9ecb8bc58ccf95dc5c06 \ 331 | --hash=sha256:f4f37bbc6588d402980ffbd1f3338c871368fb4b1cfa091debe13c68bb3852b3 \ 332 | --hash=sha256:f8651cf1f144f9ee0fa7d1a1df61a9184ab72962531ca99f077bbdcba3947c58 \ 333 | --hash=sha256:f955aa50d7d5220fcb6e38f69ea126eafecd812d96aeed5d5f3597f33fad43bb \ 334 | --hash=sha256:fc10da7e7df3380426521e8c1ed975d22df678639da2ed0ec3244c3dc2ab54c8 \ 335 | --hash=sha256:fdca0511458d26cf39b827a663d7d87db6f32b93efc22442a742035728603d5f 336 | # via -r requirements.in 337 | pycodestyle==2.9.1 \ 338 | --hash=sha256:2c9607871d58c76354b697b42f5d57e1ada7d261c261efac224b664affdc5785 \ 339 | --hash=sha256:d1735fc58b418fd7c5f658d28d943854f8a849b01a5d0a1e6f3f3fdd0166804b 340 | # via flake8 341 | pycparser==2.21 \ 342 | --hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \ 343 | --hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206 344 | # via cffi 345 | pyflakes==2.5.0 \ 346 | --hash=sha256:4579f67d887f804e67edb544428f264b7b24f435b263c4614f384135cea553d2 \ 347 | --hash=sha256:491feb020dca48ccc562a8c0cbe8df07ee13078df59813b83959cbdada312ea3 348 | # via flake8 349 | pyproject-hooks==1.0.0 \ 350 | --hash=sha256:283c11acd6b928d2f6a7c73fa0d01cb2bdc5f07c57a2eeb6e83d5e56b97976f8 \ 351 | --hash=sha256:f271b298b97f5955d53fb12b72c1fb1948c22c1a6b70b315c54cedaca0264ef5 352 | # via build 353 | python-decouple==3.8 \ 354 | --hash=sha256:ba6e2657d4f376ecc46f77a3a615e058d93ba5e465c01bbe57289bfb7cce680f \ 355 | --hash=sha256:d0d45340815b25f4de59c974b855bb38d03151d81b037d9e3f463b0c9f8cbd66 356 | # via -r requirements.in 357 | python-dotenv==1.0.0 \ 358 | --hash=sha256:a8df96034aae6d2d50a4ebe8216326c61c3eb64836776504fcca410e5937a3ba \ 359 | --hash=sha256:f5971a9226b701070a4bf2c38c89e5a3f0d64de8debda981d1db98583009122a 360 | # via -r requirements.in 361 | requests==2.26.0 \ 362 | --hash=sha256:6c1246513ecd5ecd4528a0906f910e8f0f9c6b8ec72030dc9fd154dc1a6efd24 \ 363 | --hash=sha256:b8aa58f8cf793ffd8782d3d8cb19e66ef36f7aba4353eec859e74678b01b07a7 364 | # via 365 | # -r requirements.in 366 | # rollbar 367 | rollbar==0.16.3 \ 368 | --hash=sha256:02313dfc60710ec736ab033d0f8c969d857a8b991cd67e0c1a91620e8a04ede2 \ 369 | --hash=sha256:f06e23b36d7d1b547f32f287da9367b9bf53319f611da0ec9e891859507ac94e 370 | # via -r requirements.in 371 | six==1.16.0 \ 372 | --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \ 373 | --hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254 374 | # via rollbar 375 | sqlalchemy==1.4.17 \ 376 | --hash=sha256:196fb6bb2733834e506c925d7532f8eabad9d2304deef738a40846e54c31e236 \ 377 | --hash=sha256:1dd77acbc19bee9c0ba858ff5e4e5d5c60895495c83b4df9bcdf4ad5e9b74f21 \ 378 | --hash=sha256:216ff28fe803885ceb5b131dcee6507d28d255808dd5bcffcb3b5fa75be2e102 \ 379 | --hash=sha256:461a4ea803ce0834822f372617a68ac97f9fa1281f2a984624554c651d7c3ae1 \ 380 | --hash=sha256:4b09191ed22af149c07a880f309b7740f3f782ff13325bae5c6168a6aa57e715 \ 381 | --hash=sha256:4c5e20666b33b03bf7f14953f0deb93007bf8c1342e985bd7c7cf25f46fac579 \ 382 | --hash=sha256:4d93b62e98248e3e1ac1e91c2e6ee1e7316f704be1f734338b350b6951e6c175 \ 383 | --hash=sha256:5732858e56d32fa7e02468f4fd2d8f01ddf709e5b93d035c637762890f8ed8b6 \ 384 | --hash=sha256:58c02d1771bb0e61bc9ced8f3b36b5714d9ece8fd4bdbe2a44a892574c3bbc3c \ 385 | --hash=sha256:651cdb3adcee13624ba22d5ff3e96f91e16a115d2ca489ddc16a8e4c217e8509 \ 386 | --hash=sha256:6fe1c8dc26bc0005439cb78ebc78772a22cccc773f5a0e67cb3002d791f53f0f \ 387 | --hash=sha256:7222f3236c280fab3a2d76f903b493171f0ffc29667538cc388a5d5dd0216a88 \ 388 | --hash=sha256:7dc3d3285fb682316d580d84e6e0840fdd8ffdc05cb696db74b9dd746c729908 \ 389 | --hash=sha256:7e45043fe11d503e1c3f9dcf5b42f92d122a814237cd9af68a11dae46ecfcae1 \ 390 | --hash=sha256:7eb55d5583076c03aaf1510473fad2a61288490809049cb31028af56af7068ee \ 391 | --hash=sha256:82922a320d38d7d6aa3a8130523ec7e8c70fa95f7ca7d0fd6ec114b626e4b10b \ 392 | --hash=sha256:8e133e2551fa99c75849848a4ac08efb79930561eb629dd7d2dc9b7ee05256e6 \ 393 | --hash=sha256:949ac299903d2ed8419086f81847381184e2264f3431a33af4679546dcc87f01 \ 394 | --hash=sha256:a2d225c8863a76d15468896dc5af36f1e196b403eb9c7e0151e77ffab9e7df57 \ 395 | --hash=sha256:a5f00a2be7d777119e15ccfb5ba0b2a92e8a193959281089d79821a001095f80 \ 396 | --hash=sha256:b0ad951a6e590bbcfbfeadc5748ef5ec8ede505a8119a71b235f7481cc08371c \ 397 | --hash=sha256:b59b2c0a3b1d93027f6b6b8379a50c354483fe1ebe796c6740e157bb2e06d39a \ 398 | --hash=sha256:bc89e37c359dcd4d75b744e5e81af128ba678aa2ecea4be957e80e6e958a1612 \ 399 | --hash=sha256:bde055c019e6e449ebc4ec61abd3e08690abeb028c7ada2a3b95d8e352b7b514 \ 400 | --hash=sha256:c367ed95d41df584f412a9419b5ece85b0d6c2a08a51ae13ae47ef74ff9a9349 \ 401 | --hash=sha256:dde05ae0987e43ec84e64d6722ce66305eda2a5e2b7d6fda004b37aabdfbb909 \ 402 | --hash=sha256:ee6e7ca09ff274c55d19a1e15ee6f884fa0230c0d9b8d22a456e249d08dee5bf \ 403 | --hash=sha256:f1c68f7bd4a57ffdb85eab489362828dddf6cd565a4c18eda4c446c1d5d3059d \ 404 | --hash=sha256:f63e1f531a8bf52184e2afb53648511f3f8534decb7575b483a583d3cd8d13ed \ 405 | --hash=sha256:fdad4a33140b77df61d456922b7974c1f1bb2c35238f6809f078003a620c4734 406 | # via flask-sqlalchemy 407 | tomli==1.2.3 \ 408 | --hash=sha256:05b6166bff487dc068d322585c7ea4ef78deed501cc124060e0f238e89a9231f \ 409 | --hash=sha256:e3069e4be3ead9668e21cb9b074cd948f7b3113fd9c8bba083f48247aab8b11c 410 | # via 411 | # black 412 | # build 413 | # pip-tools 414 | # pyproject-hooks 415 | typing-extensions==4.7.1 \ 416 | --hash=sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36 \ 417 | --hash=sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2 418 | # via black 419 | urllib3==1.26.5 \ 420 | --hash=sha256:753a0374df26658f99d826cfe40394a686d05985786d946fbe4165b5148f5a7c \ 421 | --hash=sha256:a7acd0977125325f516bda9735fa7142b909a8d01e8b2e4c8108d0984e6e0098 422 | # via requests 423 | werkzeug==2.0.1 \ 424 | --hash=sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42 \ 425 | --hash=sha256:6c1ec500dcdba0baa27600f6a22f6333d8b662d22027ff9f6202e3367413caa8 426 | # via flask 427 | wheel==0.41.2 \ 428 | --hash=sha256:0c5ac5ff2afb79ac23ab82bab027a0be7b5dbcf2e54dc50efe4bf507de1f7985 \ 429 | --hash=sha256:75909db2664838d015e3d9139004ee16711748a52c8f336b52882266540215d8 430 | # via pip-tools 431 | zipp==3.16.2 \ 432 | --hash=sha256:679e51dd4403591b2d6838a48de3d283f3d188412a9782faadf845f298736ba0 \ 433 | --hash=sha256:ebc15946aa78bd63458992fc81ec3b6f7b1e92d51c35e6de1c3804e73b799147 434 | # via importlib-metadata 435 | 436 | # WARNING: The following packages were not pinned, but pip requires them to be 437 | # pinned when the requirements file includes hashes and the requirement is not 438 | # satisfied by a package already installed. Consider using the --allow-unsafe flag. 439 | # pip 440 | # setuptools 441 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/screenshot.png -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 1rem; 3 | } 4 | 5 | #error, 6 | #culprits-error { 7 | display: none; 8 | padding: 100px; 9 | color: red; 10 | } 11 | 12 | td a { 13 | padding-right: 5px; 14 | } 15 | 16 | #cloak { 17 | padding: 100px 0; 18 | height: 100%; 19 | width: 100%; 20 | } 21 | 22 | #cloak p { 23 | text-align: center; 24 | } 25 | 26 | a.resolved { 27 | text-decoration: line-through !important; 28 | } 29 | 30 | .page-header.culprits { 31 | margin-top: 60px; 32 | } 33 | 34 | .culprits h4 { 35 | margin-top: 35px; 36 | } 37 | 38 | input.form-control.name { 39 | margin-top: 10px; 40 | } 41 | 42 | a { 43 | color: rgb(51, 122, 183); 44 | } 45 | 46 | td.checked { 47 | background: url(./static/check.png) no-repeat; 48 | background-position: center; 49 | } 50 | 51 | .media-body h5 { 52 | margin-bottom: 0; 53 | } 54 | 55 | .user-avatar-group { 56 | vertical-align: middle; 57 | width: 44px; 58 | height: 44px; 59 | margin-right: 6px; 60 | padding: 0; 61 | position: relative; 62 | display: inline-block; 63 | } 64 | 65 | .user-avatar:first-child { 66 | width: 44px; 67 | height: 44px; 68 | } 69 | 70 | .user-avatar:not(:first-child) { 71 | width: 22px; 72 | height: 22px; 73 | position: absolute; 74 | bottom: 0; 75 | right: -6px; 76 | } 77 | 78 | .user-avatar > img { 79 | border-radius: 3px; /* this is what GitHub uses */ 80 | width: inherit; 81 | height: inherit; 82 | } 83 | 84 | .user-avatar.unknown-user { 85 | display: inline-block; 86 | /* border: 1px solid black; */ 87 | background: #ccd; 88 | border-radius: 3px; /* this is what GitHub uses */ 89 | } 90 | 91 | .user-avatar.unknown-user::after { 92 | content: '?'; 93 | line-height: 44px; 94 | display: block; 95 | text-align: center; 96 | font-size: 1.5em; 97 | } 98 | 99 | #whatisit { 100 | margin-top: 150px; 101 | } 102 | 103 | #previous { 104 | margin-top: 50px; 105 | } 106 | 107 | #previous li .names { 108 | margin-left: 10px; 109 | font-size: 80%; 110 | color: #666; 111 | } 112 | 113 | a.compare-url { 114 | font-size: 80%; 115 | } 116 | 117 | .commit-date { 118 | font-weight: normal; 119 | color: #666; 120 | } 121 | 122 | footer { 123 | margin-top: 60px; 124 | } 125 | footer p { 126 | text-align: center; 127 | } 128 | 129 | .deployed-metadata { 130 | display: flex; 131 | flex-direction: row; 132 | flex-wrap: wrap; 133 | } 134 | 135 | .deployed-metadata > div { 136 | flex: 1 0 50%; 137 | } 138 | 139 | .deployed-metadata > div.metadata-actions { 140 | text-align: right; 141 | } 142 | 143 | th > .column-extra { 144 | float: right; 145 | font-weight: normal; 146 | margin-right: 20px; 147 | } 148 | 149 | h2 .reponame { 150 | color: #666; 151 | } 152 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter as Router } from 'react-router-dom'; 3 | import 'bootstrap/dist/css/bootstrap.min.css'; 4 | 5 | import Routes from './Routes'; 6 | import './App.css'; 7 | 8 | export default class App extends React.Component { 9 | render() { 10 | return ( 11 | 12 |
13 | 14 |
15 |
16 |
17 | ); 18 | } 19 | } 20 | 21 | class Footer extends React.Component { 22 | render() { 23 | let versionData; 24 | if (document.querySelector('#_version')) { 25 | versionData = Object.assign( 26 | {}, 27 | document.querySelector('#_version').dataset, 28 | ); 29 | } 30 | 31 | return ( 32 | 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/AutoProgressBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Progress } from 'reactstrap'; 4 | 5 | export default class AutoProgressBar extends React.Component { 6 | static propTypes = { 7 | done: PropTypes.number, 8 | total: PropTypes.number, 9 | targetTime: PropTypes.number, 10 | auto: PropTypes.bool, 11 | autoTickRate: PropTypes.number, 12 | }; 13 | 14 | static defaultProps = { 15 | targetTime: 5000, 16 | auto: true, 17 | autoTickRate: 100, 18 | }; 19 | 20 | constructor(props) { 21 | super(props); 22 | this.state = { 23 | autoCounter: 0, 24 | }; 25 | this.interval = null; 26 | } 27 | 28 | componentDidMount() { 29 | this.interval = setInterval(() => this.tick(), this.props.autoTickRate); 30 | this.setState({ autoCounter: 0 }); 31 | } 32 | 33 | componentWillUnmount() { 34 | clearInterval(this.interval); 35 | } 36 | 37 | tick() { 38 | const { autoTickRate, targetTime } = this.props; 39 | this.setState((state) => { 40 | const next = state.autoCounter + autoTickRate; 41 | if (next > targetTime) { 42 | clearInterval(this.interval); 43 | } 44 | return { autoCounter: next }; 45 | }); 46 | } 47 | 48 | render() { 49 | const { auto, done, total, targetTime } = this.props; 50 | const { autoCounter } = this.state; 51 | let displayedProgress = 0; 52 | if (done && total) { 53 | displayedProgress = Math.max(displayedProgress, done / total); 54 | } 55 | if (auto) { 56 | let fakeProgress = autoCounter / targetTime; 57 | displayedProgress = Math.max(displayedProgress, fakeProgress); 58 | } 59 | if (displayedProgress > 1) { 60 | displayedProgress = 1; 61 | } 62 | 63 | return ( 64 | 71 | {displayedProgress >= 0.99 && 'This is taking a while...'} 72 | 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Common.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class EllipsisLoading extends React.PureComponent { 4 | state = { dots: 0 }; 5 | static defaultProps = { 6 | text: 'Loading', 7 | animationMs: 400, 8 | maxDots: 5, 9 | }; 10 | componentDidMount() { 11 | this.interval = window.setInterval(() => { 12 | this.setState((state) => { 13 | return { dots: (state.dots + 1) % this.props.maxDots }; 14 | }); 15 | }, this.props.animationMs); 16 | } 17 | componentWillUnmount() { 18 | window.clearInterval(this.interval); 19 | } 20 | render() { 21 | let dots = '.'.repeat(this.state.dots + 1); 22 | return `${this.props.text}${dots}`; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/DeployPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ky from 'ky/umd'; 4 | import TimeAgo from 'react-timeago'; 5 | import classNames from 'classnames'; 6 | import { Link } from 'react-router-dom'; 7 | import { CopyToClipboard } from 'react-copy-to-clipboard'; 8 | 9 | import AutoProgressBar from './AutoProgressBar'; 10 | import shortUrls from './shortUrls'; 11 | import { withRouter } from './Routes'; 12 | import { EllipsisLoading } from './Common'; 13 | 14 | const BORS_LOGIN = 'bors[bot]'; 15 | 16 | function makeTagAbsoluteUrl(owner, repo, tag) { 17 | return `https://github.com/${owner}/${repo}/releases/tag/${encodeURIComponent( 18 | tag, 19 | )}`; 20 | } 21 | 22 | class DeployPage extends React.Component { 23 | static propsTypes = { 24 | shortCode: PropTypes.string.isRequired, 25 | }; 26 | 27 | state = { 28 | owner: null, 29 | repo: null, 30 | deployments: null, 31 | commits: null, 32 | deployInfo: null, 33 | error: null, 34 | loading: null, 35 | tags: null, 36 | }; 37 | 38 | isLoading() { 39 | if (this.state.loading === null) { 40 | return true; 41 | } 42 | return this.state.loading.size > 0; 43 | } 44 | 45 | startLoad(name) { 46 | this.setState(({ loading }) => { 47 | if (!loading) { 48 | loading = new Set(); 49 | } 50 | loading.add(name); 51 | return { loading }; 52 | }); 53 | } 54 | 55 | finishLoad(name) { 56 | this.setState((state) => state.loading.delete(name)); 57 | } 58 | 59 | async decodeShortCode() { 60 | const { 61 | history, 62 | match: { params }, 63 | } = this.props; 64 | this.startLoad('parameters'); 65 | try { 66 | let { owner, repo, deployments } = await shortUrls.decode(params.code); 67 | this.setState({ owner, repo, deployments }); 68 | if (params.owner !== owner || params.repo !== repo) { 69 | history.replace(`/s/${params.code}/${owner}/${repo}`); 70 | } 71 | 72 | this.fetchShas(); 73 | this.fetchCommits(); 74 | this.finishLoad('parameters'); 75 | } catch (error) { 76 | this.setState({ error }); 77 | } 78 | } 79 | 80 | async fetchShas() { 81 | const { owner, repo, deployments } = this.state; 82 | this.startLoad('shas'); 83 | try { 84 | const res = await ky 85 | .post('/shas', { json: { owner, repo, deployments } }) 86 | .json(); 87 | 88 | if (res.error) { 89 | this.setState({ error: res.error }); 90 | } else { 91 | this.setState({ 92 | deployInfo: res.deployments, 93 | tags: res.tags, 94 | }); 95 | } 96 | } catch (error) { 97 | console.warn('Error fetching shas!'); 98 | console.error(error); 99 | this.setState({ error }); 100 | } finally { 101 | this.finishLoad('shas'); 102 | } 103 | } 104 | 105 | async fetchCommits() { 106 | const { owner, repo } = this.state; 107 | this.startLoad('commits'); 108 | try { 109 | const commitsUrl = new URL(window.location.origin); 110 | commitsUrl.pathname = '/githubapi/commits'; 111 | commitsUrl.searchParams.set('owner', owner); 112 | commitsUrl.searchParams.set('repo', repo); 113 | commitsUrl.searchParams.set('per_page', 100); 114 | 115 | const commits = await ky.get(commitsUrl).json(); 116 | 117 | this.setState({ commits }); 118 | } catch (error) { 119 | this.setState({ error }); 120 | } finally { 121 | this.finishLoad('commits'); 122 | } 123 | } 124 | 125 | update(props = this.props) { 126 | this.decodeShortCode(); 127 | } 128 | 129 | componentDidMount() { 130 | this.update(); 131 | } 132 | 133 | componentWillReceiveProps(newProps) { 134 | this.update(); 135 | } 136 | 137 | render() { 138 | const { 139 | match: { 140 | params: { code }, 141 | }, 142 | } = this.props; 143 | const { error, loading, deployInfo, commits, tags, owner, repo } = 144 | this.state; 145 | 146 | document.title = `What's Deployed on ${owner}/${repo}?`; 147 | 148 | return ( 149 |
150 |

151 | What's Deployed{' '} 152 | {owner && repo && ( 153 | 154 | {' '} 155 | on{' '} 156 | 160 | {owner}/{repo} 161 | 162 | 163 | )} 164 | ? 165 |

166 | {error &&
{error.toString()}
} 167 | {this.isLoading() ? ( 168 | <> 169 | Loading {Array.from(loading || '...').join(' and ')} 170 | 175 | 176 | ) : !deployInfo ? ( 177 |
178 | No Deployment info could be found 179 |
180 | ) : ( 181 | <> 182 | 190 | 196 | 197 | 198 | )} 199 |
200 | ); 201 | } 202 | } 203 | 204 | export default withRouter(DeployPage); 205 | 206 | class DeployTable extends React.Component { 207 | static propTypes = { 208 | deployInfo: PropTypes.arrayOf( 209 | PropTypes.shape({ name: PropTypes.string.isRequired }), 210 | ).isRequired, 211 | commits: PropTypes.arrayOf(PropTypes.shape({})).isRequired, 212 | tags: PropTypes.object.isRequired, 213 | code: PropTypes.string.isRequired, 214 | owner: PropTypes.string.isRequired, 215 | repo: PropTypes.string.isRequired, 216 | }; 217 | static prefBorsModeCacheKey = 'pref-bors-mode'; 218 | 219 | state = { 220 | borsMode: false, 221 | }; 222 | 223 | componentDidMount() { 224 | this._restoreBorsModeChoice(); 225 | } 226 | 227 | handleBorsCheckbox = (ev) => { 228 | this.setState({ borsMode: ev.target.checked }, this._persistBorsModeChoice); 229 | }; 230 | 231 | _restoreBorsModeChoice = () => { 232 | const prefs = JSON.parse( 233 | localStorage.getItem(this.prefBorsModeCacheKey) || '{}', 234 | ); 235 | if ( 236 | this.props.code in prefs && 237 | prefs[this.props.code] !== this.state.borsMode 238 | ) { 239 | this.setState({ borsMode: prefs[this.props.code] }); 240 | } 241 | }; 242 | 243 | _persistBorsModeChoice = () => { 244 | const prefs = JSON.parse( 245 | localStorage.getItem(this.prefBorsModeCacheKey) || '{}', 246 | ); 247 | prefs[this.props.code] = this.state.borsMode; 248 | localStorage.setItem(this.prefBorsModeCacheKey, JSON.stringify(prefs)); 249 | }; 250 | 251 | render() { 252 | const { deployInfo, commits, tags, owner, repo, code } = this.props; 253 | const shortUrl = `/s/${code}`; 254 | 255 | const { borsMode } = this.state; 256 | 257 | let hasBors = false; 258 | 259 | const foundDeploy = {}; 260 | for (const deploy of deployInfo) { 261 | foundDeploy[deploy.name] = false; 262 | } 263 | 264 | const usersByLogin = new Map(); 265 | for (const commit of commits) { 266 | if (!commit.author || usersByLogin.has(commit.author.login)) { 267 | continue; 268 | } 269 | usersByLogin.set(commit.author.login, commit.author); 270 | } 271 | 272 | const commitRows = []; 273 | let foundMatch = false; 274 | for (const commit of commits) { 275 | for (const deploy of deployInfo) { 276 | if (commit.sha === deploy.sha) { 277 | foundDeploy[deploy.name] = true; 278 | if (Array.from(Object.values(foundDeploy)).every((f) => f)) { 279 | foundMatch = true; 280 | } 281 | } 282 | } 283 | 284 | if ( 285 | commit.author && 286 | commit.author.login === BORS_LOGIN && 287 | commit.author.type === 'Bot' 288 | ) { 289 | hasBors = true; 290 | } else if (borsMode) { 291 | continue; 292 | } 293 | 294 | commitRows.push( 295 | 296 | 304 | {deployInfo.map((deploy) => ( 305 | 309 | ))} 310 | , 311 | ); 312 | 313 | if (foundMatch) { 314 | break; 315 | } 316 | } 317 | 318 | return ( 319 | <> 320 | 321 | 322 | 323 | 337 | {deployInfo.map((deployment) => ( 338 | 339 | ))} 340 | 341 | 342 | {commitRows} 343 |
324 | Commits on master 325 | {hasBors && ( 326 | 327 | There are commits here by {BORS_LOGIN}.{' '} 328 | {' '} 333 | Enable "bors mode" 334 | 335 | )} 336 | {deployment.name}
344 | 345 |
346 | {foundMatch ? ( 347 |
348 | Stopping as soon as all environments have a particular commit 349 | common 350 |
351 | ) : ( 352 |
353 | Even after comparing the last {commits.length} commits, a common 354 | denominator could not be found! The difference is just too big. 355 |
356 | Use the links below to compare directly on GitHub. 357 |
358 | )} 359 | 365 |
366 | 367 | ); 368 | } 369 | } 370 | 371 | class CommitDetails extends React.Component { 372 | static propTypes = { 373 | commit: PropTypes.shape({ message: PropTypes.string.isRequired }) 374 | .isRequired, 375 | author: PropTypes.shape({ 376 | login: PropTypes.string.isRequired, 377 | avatar_url: PropTypes.string.isRequired, 378 | html_url: PropTypes.string.isRequired, 379 | }), 380 | html_url: PropTypes.string.isRequired, 381 | owner: PropTypes.string.isRequired, 382 | repo: PropTypes.string.isRequired, 383 | tag: PropTypes.any, 384 | }; 385 | 386 | parseBorsMessage(commit) { 387 | /* Extract out the lines that are generated by bors as the 388 | real commit message. Then return these with a '; ' delimiter. An example 389 | (full) bors commit message can look like this: 390 | -------------------------------------------------------------------------- 391 | Merge #1520 392 | 393 | 1520: Update python:3.6 Docker digest to 7eced2 r=mythmon a=renovate[bot] 394 | 395 |

This Pull Request updates Docker base image python:3.6-slim to the latest digest (sha256:7eced2b....f967188845). For details on Renovate's Docker support, please visit https://renovatebot.com/docs/docker

396 |
397 |

This PR has been generated by Renovate Bot.

398 | 399 | Co-authored-by: Renovate Bot 400 | -------------------------------------------------------------------------- 401 | */ 402 | const { users } = this.props; 403 | let headers = commit.message 404 | .split(/\n\n/g) 405 | .filter((paragraph) => /^\d+: /.test(paragraph)); 406 | 407 | return { 408 | description: headers.join('; '), 409 | authors: headers 410 | .map((header) => { 411 | const match = header.match(/a=([^ ]*)+/); 412 | if (match) { 413 | let [, author] = match; 414 | if (author === 'renovate[bot]') { 415 | author = 'renovate-bot'; 416 | } 417 | if (users.has(author)) { 418 | return users.get(author); 419 | } 420 | } 421 | return null; 422 | }) 423 | .filter((login) => login), 424 | }; 425 | } 426 | 427 | render() { 428 | let { commit, author, tag, html_url, borsMode, owner, repo } = this.props; 429 | 430 | let involvedUsers = []; 431 | if (author) { 432 | involvedUsers.push(author); 433 | } 434 | 435 | let title; 436 | if (borsMode && author.login === BORS_LOGIN && author.type === 'Bot') { 437 | const { description, authors } = this.parseBorsMessage(commit); 438 | title = description; 439 | for (const author of authors) { 440 | if ( 441 | author && 442 | !involvedUsers.map((u) => u.login).includes(author.login) 443 | ) { 444 | involvedUsers.unshift(author); 445 | } 446 | } 447 | } else { 448 | title = commit.message.split(/\n\n+/)[0]; 449 | } 450 | 451 | return ( 452 | 453 | 454 | 455 | {title} 456 | 457 | {tag && ( 458 | 463 | {tag} 464 | 465 | )} 466 | {commit.date} 467 | 471 | 472 | ); 473 | } 474 | } 475 | 476 | function UserAvatars({ users }) { 477 | return ( 478 |
479 | {users.map((user) => { 480 | if (!user) { 481 | return ( 482 | 483 | ); 484 | } else { 485 | const { html_url, login, avatar_url } = user; 486 | return ( 487 | 493 | {login} 494 | 495 | ); 496 | } 497 | })} 498 |
499 | ); 500 | } 501 | 502 | class RepoSummary extends React.Component { 503 | static propTypes = { 504 | deployInfo: PropTypes.arrayOf( 505 | PropTypes.shape({ name: PropTypes.string.isRequired }), 506 | ).isRequired, 507 | owner: PropTypes.string.isRequired, 508 | repo: PropTypes.string.isRequired, 509 | tags: PropTypes.object.isRequired, 510 | }; 511 | 512 | render() { 513 | const { deployInfo, tags, owner, repo } = this.props; 514 | 515 | const repoUrl = `https://github.com/${owner}/${repo}`; 516 | 517 | return ( 518 | <> 519 |

Repository Info

520 |

521 | {repoUrl} 522 |

523 | 524 | 525 | 526 | 527 | 528 | {deployInfo.map((deployment) => ( 529 | 530 | ))} 531 | 532 | 533 | 534 | {deployInfo.map((deployment) => ( 535 | 536 | 545 | 553 | {deployInfo.map((otherDeployment) => { 554 | if (otherDeployment.name === deployment.name) { 555 | return ; 556 | } else { 557 | return ( 558 | 566 | ); 567 | } 568 | })} 569 | 570 | ))} 571 | 572 |
Revision URLsSHA{deployment.name}
537 | 542 | {deployment.name} 543 | 544 | 546 | 552 | - 559 | 562 | Compare {deployment.name} ↔{' '} 563 | {otherDeployment.name} 564 | 565 |
573 | 574 | ); 575 | } 576 | } 577 | 578 | class ShaLink extends React.Component { 579 | render() { 580 | const { sha, owner, repo, tags = {} } = this.props; 581 | const tag = tags[sha]; 582 | return ( 583 | <> 584 | 585 | {sha.slice(0, 7)} 586 | 587 | {tag && ( 588 | 593 | {tag} 594 | 595 | )} 596 | 597 | ); 598 | } 599 | } 600 | 601 | class Culprits extends React.PureComponent { 602 | state = { 603 | loading: true, 604 | culprits: null, 605 | error: null, 606 | }; 607 | 608 | componentDidMount() { 609 | this.controller = new AbortController(); 610 | this.fetchCulprits(); 611 | } 612 | 613 | componentWillUnmount() { 614 | this.controller.abort(); 615 | this.dismounted = true; 616 | } 617 | 618 | async fetchCulprits() { 619 | const { owner, repo, deployInfo } = this.props; 620 | this.setState({ loading: true }); 621 | 622 | const { signal } = this.controller; 623 | try { 624 | const res = await ky 625 | .post('/culprits', { 626 | signal, 627 | json: { owner, repo, deployments: deployInfo }, 628 | }) 629 | .json(); 630 | if (this.dismounted) return; 631 | if (res.error) { 632 | this.setState({ error: res.error }); 633 | } else { 634 | this.setState({ culprits: res.culprits }); 635 | } 636 | } catch (error) { 637 | if (this.dismounted) return; 638 | this.setState({ error }); 639 | } finally { 640 | if (this.dismounted) return; 641 | this.setState({ loading: false }); 642 | } 643 | } 644 | 645 | render() { 646 | const { loading, culprits, error } = this.state; 647 | return ( 648 | <> 649 |

Culprits

650 | {error &&
{error.toString()}
} 651 | {loading && } 652 | {culprits && ( 653 |
654 | {culprits.map((group) => ( 655 |
656 |

657 | On {group.name} 658 |

659 | {group.users.map(([role, user]) => ( 660 |
661 | 662 | {user.login} 669 | 670 |
671 | 672 | {user.login} 673 | 674 |

{role}

675 |
676 |
677 | ))} 678 |
679 | ))} 680 |
681 | )} 682 | 683 | ); 684 | } 685 | } 686 | 687 | class BadgesAndUrls extends React.Component { 688 | state = { 689 | showHelp: false, 690 | textCopied: '', 691 | }; 692 | 693 | componentWillUnmount() { 694 | this.dismounted = true; 695 | } 696 | 697 | toggleHelp = () => { 698 | this.setState((state) => ({ showHelp: !state.showHelp })); 699 | }; 700 | 701 | copiedText = (textCopied) => { 702 | this.setState({ textCopied }, () => { 703 | window.setTimeout(() => { 704 | if (!this.dismounted) { 705 | this.setState({ textCopied: '' }); 706 | } 707 | }, 4 * 1000); 708 | }); 709 | }; 710 | 711 | showCopyToClipboard = (text) => { 712 | return ( 713 | this.copiedText(text)}> 714 | 715 | 723 | 724 | 725 | ); 726 | }; 727 | 728 | render() { 729 | const { deployInfo, shortUrl, owner, repo } = this.props; 730 | const { showHelp } = this.state; 731 | 732 | const { protocol, host } = window.location; 733 | const fullUrl = `${protocol}//${host}${shortUrl}/${owner}/${repo}`; 734 | const envs = deployInfo 735 | .map((deploy) => deploy.name.toLowerCase()) 736 | .join(','); 737 | const badgeUrl = `https://img.shields.io/badge/whatsdeployed-${envs}-green.svg`; 738 | const badgeAlt = `What's deployed on ${envs}?`; 739 | const markdown = `[![${badgeAlt}](${badgeUrl})](${fullUrl})`; 740 | const restructuredText = `.. |whatsdeployed| image:: ${badgeUrl}\n :target: ${fullUrl}`; 741 | 742 | return ( 743 | <> 744 |
745 |
746 | Short URL: {fullUrl} 747 |
748 | 749 |
750 | Badge:{' '} 751 | 752 | {badgeAlt} 753 | 754 | 757 |
758 |
759 | 760 | {showHelp && ( 761 |
762 |

Badge Help

763 |
764 |
Image URL {this.showCopyToClipboard(badgeUrl)}
765 |
766 |
{badgeUrl}
767 |
768 |
Markdown {this.showCopyToClipboard(markdown)}
769 |
770 |
{markdown}
771 |
772 |
773 | ReStructuredText {this.showCopyToClipboard(restructuredText)} 774 |
775 |
776 |
{restructuredText}
777 |
778 |
779 |
780 | )} 781 | 782 | ); 783 | } 784 | } 785 | -------------------------------------------------------------------------------- /src/LongUrlRedirect.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import AutoProgressBar from './AutoProgressBar'; 5 | import shortUrls from './shortUrls'; 6 | import { withRouter } from './Routes'; 7 | 8 | class LongUrlRedirect extends React.Component { 9 | state = { 10 | error: null, 11 | }; 12 | 13 | static propsTypes = { 14 | owner: PropTypes.string, 15 | repo: PropTypes.string, 16 | deployments: PropTypes.arrayOf( 17 | PropTypes.shape({ 18 | url: PropTypes.string.isRequired, 19 | name: PropTypes.string.isRequired, 20 | }), 21 | ), 22 | }; 23 | 24 | async shorten(props = this.props) { 25 | const { location, history } = this.props; 26 | 27 | const owner = location.searchParams.get('owner'); 28 | const repo = location.searchParams.get('repo'); 29 | 30 | const names = location.searchParams.getAll('name[]'); 31 | const urls = location.searchParams.getAll('url[]'); 32 | const deployments = names.map((name, idx) => ({ name, url: urls[idx] })); 33 | 34 | try { 35 | let longUrl = shortUrls.buildLongUrl({ owner, repo, deployments }); 36 | longUrl = longUrl.pathname + longUrl.search; 37 | const shortUrl = await shortUrls.fetchFor(longUrl); 38 | const code = shortUrl.slice(3); 39 | history.replace({ 40 | pathname: `/s/${code}`, 41 | search: '', 42 | hash: '', 43 | state: null, 44 | }); 45 | } catch (error) { 46 | this.setState({ error }); 47 | } 48 | } 49 | 50 | componentDidMount() { 51 | this.shorten(); 52 | } 53 | 54 | componentWillReceiveProps(newProps) { 55 | this.shorten(newProps); 56 | } 57 | 58 | render() { 59 | const { owner, repo } = this.props; 60 | const { error } = this.state; 61 | 62 | document.title = `What's Deployed on ${owner}/${repo}?`; 63 | 64 | if (error) { 65 | return
{error.toString()}
; 66 | } 67 | 68 | return ( 69 |
70 | Loading parameters 71 | 72 |
73 | ); 74 | } 75 | } 76 | 77 | export default withRouter(LongUrlRedirect); 78 | -------------------------------------------------------------------------------- /src/Routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Switch, 4 | Redirect, 5 | Route, 6 | withRouter as originalWithRouter, 7 | } from 'react-router-dom'; 8 | 9 | import SetupPage from './SetupPage'; 10 | import DeployPage from './DeployPage'; 11 | import LongUrlRedirect from './LongUrlRedirect'; 12 | 13 | const Routes = withRouter(({ location }) => { 14 | if (location.search) { 15 | return ; 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | }); 27 | 28 | export default Routes; 29 | 30 | export function withRouter(Component) { 31 | return originalWithRouter((props) => { 32 | props.location.searchParams = new URLSearchParams(props.location.search); 33 | return ; 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/SetupPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { withRouter, Link } from 'react-router-dom'; 3 | 4 | import shortUrls from './shortUrls'; 5 | import { EllipsisLoading } from './Common'; 6 | 7 | export default class SetupPage extends React.Component { 8 | render() { 9 | return ( 10 |
11 |

What's Deployed

12 | 13 | 14 | 15 |
16 | ); 17 | } 18 | } 19 | 20 | class SetupForm extends React.Component { 21 | constructor(props) { 22 | super(props); 23 | this.onChange = this.onChange.bind(this); 24 | this.addRow = this.addRow.bind(this); 25 | this.submit = this.submit.bind(this); 26 | } 27 | 28 | state = { 29 | owner: '', 30 | repository: '', 31 | rows: [ 32 | { 33 | name: '', 34 | url: '', 35 | }, 36 | ], 37 | }; 38 | 39 | onChange(ev) { 40 | const { name, value } = ev.target; 41 | this.setState({ [name]: value }); 42 | } 43 | 44 | onRowChange(rowIdx, ev) { 45 | const name = ev.target.name; 46 | const value = ev.target.value; 47 | this.setState((state) => { 48 | // replace an existing row without modifying any existing objects 49 | let newRows = [...state.rows]; 50 | newRows[rowIdx][name] = value; 51 | return { rows: newRows }; 52 | }); 53 | } 54 | 55 | addRow() { 56 | this.setState(({ rows }) => ({ 57 | rows: rows.concat([{ name: '', url: '' }]), 58 | })); 59 | } 60 | 61 | submit(ev) { 62 | const { history } = this.props; 63 | const { owner, repository, rows } = this.state; 64 | let newUrl = shortUrls.buildLongUrl({ 65 | owner, 66 | repo: repository, 67 | deployments: rows, 68 | }); 69 | ev.preventDefault(); 70 | history.push({ 71 | pathname: newUrl.pathname, 72 | search: newUrl.search, 73 | hash: '', 74 | state: null, 75 | }); 76 | } 77 | 78 | render() { 79 | const { owner, repository, rows } = this.state; 80 | document.title = "What's Deployed?"; 81 | 82 | return ( 83 |
84 |
85 | 86 | 94 |
95 |
96 | 97 | 105 |
106 | 107 | {rows.map(({ name, url }, index) => ( 108 |
109 | 117 | 125 |
126 | ))} 127 |

128 | 135 |

136 |
137 | 140 |
141 |
142 | ); 143 | } 144 | } 145 | 146 | const SetupFormWithRouter = withRouter(SetupForm); 147 | 148 | class PreviousEnvironments extends React.Component { 149 | state = { 150 | environments: [], 151 | loading: false, 152 | }; 153 | 154 | componentWillUnmount() { 155 | this.dismounted = true; 156 | } 157 | 158 | async componentDidMount() { 159 | this.setState({ loading: true }); 160 | const environments = await shortUrls.getAll(); 161 | if (!this.dismounted) { 162 | this.setState({ environments, loading: false }); 163 | } 164 | } 165 | 166 | render() { 167 | const { environments, loading } = this.state; 168 | return ( 169 | 188 | ); 189 | } 190 | } 191 | 192 | class WhatIsIt extends React.Component { 193 | render() { 194 | return ( 195 |
196 |

197 | What Is What's Deployed?{' '} 198 | Primer on what's in front of you 199 |

200 |

201 | 202 | It's a web service for visualizing the difference between code 203 | committed to master in your GitHub project compared to 204 | which code has been deployed in your dev, stage and/or production 205 | environment(s). 206 | 207 |

208 |

The Basics

209 |

210 | For this to work you need to have your code in a{' '} 211 | public GitHub repository and the git SHA that is deployed on 212 | your server(s) need to be publicly available. 213 |

214 |

215 | The git SHA needs to be the content of the URL or it can be JSON that 216 | contains a top-level key called commit. For example: 217 |

218 |
219 |           {`$ curl https://dev.example.com/deployed-version.txt\nd16cc25d58252a2b7e6bb394cbefa76b147d64d3`}
220 |         
221 |

Or, if it's JSON:

222 |
223 |           {`$ curl https://dev.example.com/deployed-version\n{"commit": "d16cc25d58252a2b7e6bb394cbefa76b147d64d3", "other": "stuff"}`}
224 |         
225 |

226 | Once you've typed in the GitHub organization, GitHub repository and at 227 | least one of these URLs you can generated a table that shows what's 228 | been deployed on the server(s) compared to what's available in the{' '} 229 | master branch. 230 |

231 |

Examples

232 | 261 |

Can I Have It?

262 |

263 | This instance is public and free for anybody to use. The{' '} 264 | source code is 265 | open and available under the{' '} 266 | MPL 2.0 license. 267 |

268 |

269 | It's just a Flask app and you 270 | can install and run your own instance if you want to use this for your 271 | private repositories. 272 |

273 |
274 | ); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | import * as serviceWorker from './serviceWorker'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | serviceWorker.register(); 8 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, 18 | ), 19 | ); 20 | 21 | export function register(config) { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Let's check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl, config); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ', 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl, config); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl, config) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then((registration) => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | 70 | // Execute callback 71 | if (config.onUpdate) { 72 | config.onUpdate(registration); 73 | } 74 | } else { 75 | // At this point, everything has been precached. 76 | // It's the perfect time to display a 77 | // "Content is cached for offline use." message. 78 | console.log('Content is cached for offline use.'); 79 | 80 | // Execute callback 81 | if (config.onSuccess) { 82 | config.onSuccess(registration); 83 | } 84 | } 85 | } 86 | }; 87 | }; 88 | }) 89 | .catch((error) => { 90 | console.error('Error during service worker registration:', error); 91 | }); 92 | } 93 | 94 | function checkValidServiceWorker(swUrl, config) { 95 | // Check if the service worker can be found. If it can't reload the page. 96 | fetch(swUrl) 97 | .then((response) => { 98 | // Ensure service worker exists, and that we really are getting a JS file. 99 | if ( 100 | response.status === 404 || 101 | response.headers.get('content-type').indexOf('javascript') === -1 102 | ) { 103 | // No service worker found. Probably a different app. Reload the page. 104 | navigator.serviceWorker.ready.then((registration) => { 105 | registration.unregister().then(() => { 106 | window.location.reload(); 107 | }); 108 | }); 109 | } else { 110 | // Service worker found. Proceed as normal. 111 | registerValidSW(swUrl, config); 112 | } 113 | }) 114 | .catch(() => { 115 | console.log( 116 | 'No internet connection found. App is running in offline mode.', 117 | ); 118 | }); 119 | } 120 | 121 | export function unregister() { 122 | if ('serviceWorker' in navigator) { 123 | navigator.serviceWorker.ready.then((registration) => { 124 | registration.unregister(); 125 | }); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/shortUrls.js: -------------------------------------------------------------------------------- 1 | import ky from 'ky/umd'; 2 | 3 | let maxHistory = 5; 4 | let history; 5 | let cache; 6 | 7 | const shortUrls = { 8 | /** Get the short URL for a long URL. */ 9 | async fetchFor(longUrl) { 10 | const { url: shortUrl } = await ky 11 | .post('/shortenit', { json: { url: longUrl } }) 12 | .json(); 13 | shortUrls.addShortUrl(shortUrl); 14 | return shortUrl; 15 | }, 16 | 17 | /** Get the long URL for a short URL. */ 18 | async decode(code) { 19 | if (code.startsWith('s-')) { 20 | code = code.slice(2); 21 | } 22 | const params = await ky.get(`/lengthenit/${code}`).json(); 23 | return params; 24 | }, 25 | 26 | /** Get the metadata from the server for all shortUrls in the history. */ 27 | async getAll() { 28 | if (!cache) { 29 | if (!history) { 30 | try { 31 | let json = localStorage.getItem('shortUrls') || '[]'; 32 | history = JSON.parse(json); 33 | } catch (err) { 34 | console.error( 35 | 'Error loading cached shortUrls from localStorage:', 36 | err, 37 | ); 38 | localStorage.removeItem('shortUrls'); 39 | history = []; 40 | } 41 | } 42 | 43 | if (history.length > 0) { 44 | const url = new URL(window.location.origin); 45 | url.pathname = '/shortened'; 46 | url.searchParams.set('urls', history.join(',')); 47 | const { environments } = await ky.get(url).json(); 48 | cache = environments; 49 | return environments; 50 | } else { 51 | cache = []; 52 | } 53 | } 54 | 55 | return cache; 56 | }, 57 | 58 | /** 59 | * Add a shortUrl to the cached history. If it is already in the history, 60 | * bring it to the front. 61 | */ 62 | addShortUrl(shortUrl) { 63 | if (history) { 64 | let idx = history.indexOf(shortUrl); 65 | if (idx !== -1) { 66 | history.splice(idx, 1); 67 | } 68 | history.unshift(shortUrl); 69 | history = history.slice(0, maxHistory); 70 | } else { 71 | history = [shortUrl]; 72 | } 73 | 74 | cache = null; 75 | localStorage.setItem('shortUrls', JSON.stringify(history)); 76 | }, 77 | 78 | /** Build a long URL from parts */ 79 | buildLongUrl({ owner, repo, deployments }) { 80 | let newUrl = new URL(window.location); 81 | newUrl.pathName = '/'; 82 | newUrl.search = ''; 83 | newUrl.searchParams.append('owner', owner); 84 | newUrl.searchParams.append('repo', repo); 85 | for (const { name, url } of deployments) { 86 | newUrl.searchParams.append('name[]', name); 87 | newUrl.searchParams.append('url[]', url); 88 | } 89 | return newUrl; 90 | }, 91 | }; 92 | export default shortUrls; 93 | -------------------------------------------------------------------------------- /src/static/check.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterbe/whatsdeployed/a3379ccad8ae44282558a4f3189a77ed85e84201/src/static/check.png --------------------------------------------------------------------------------