├── .envrc ├── .flaskenv ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── CODEOWNERS ├── Dockerfile ├── GithubAppPostInstall.md ├── LICENSE ├── README.md ├── cli.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.frozen.txt ├── requirements.txt ├── setup.md ├── src ├── __init__.py ├── github_app.py ├── github_sdk.py ├── main.py ├── sentry_config.py └── web_app_handler.py └── tests ├── __init__.py ├── conftest.py ├── fixtures ├── jobA │ ├── job.json │ ├── runs.json │ ├── trace.json │ └── workflow.json ├── skipped_workflow.json └── webhook_event.json ├── test_github_sdk.py ├── test_sentry_config_file.py └── test_web_app_handler.py /.envrc: -------------------------------------------------------------------------------- 1 | # shellcheck shell=bash 2 | missing() { 3 | ! command -v "$1" >/dev/null 2>&1 4 | } 5 | 6 | set -e 7 | 8 | if missing pre-commit; then 9 | echo >&2 "You're missing pre-commit. Install it with brew install pre-commit" 10 | return 1 11 | fi 12 | # If installed, it executes very fast 13 | pre-commit install >/dev/null 14 | # Convenient if you want secrets to load (e.g. personal token, Github webhook secret) or override values from env.development 15 | dotenv_if_exists .env 16 | 17 | if [ ! -d .venv ]; then 18 | python3 -m venv .venv 19 | .venv/bin/pip install -U pip 20 | .venv/bin/pip install wheel 21 | .venv/bin/pip install -r requirements.txt -r requirements-dev.txt 22 | fi 23 | 24 | source .venv/bin/activate 25 | 26 | # https://github.com/direnv/direnv/wiki/PS1 27 | unset PS1 28 | -------------------------------------------------------------------------------- /.flaskenv: -------------------------------------------------------------------------------- 1 | FLASK_APP="src.main" 2 | FLASK_ENV="development" 3 | FLASK_HOST="0.0.0.0" 4 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**.md" 7 | push: 8 | branches: 9 | - "main" 10 | paths-ignore: 11 | - "**.md" 12 | 13 | jobs: 14 | # In reality, this will not be completely accurate as only when a Docker image is deployed to GCP 15 | # we will truly have a deployed release. Nevertheless, this will be useful once it is deployed 16 | # since it will help add commits to releases 17 | release: 18 | if: ${{ github.ref == 'refs/heads/main' }} 19 | name: Create a Sentry release 20 | runs-on: ubuntu-latest 21 | timeout-minutes: 10 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v3 25 | with: 26 | # This is necessary for the a release to include commits 27 | fetch-depth: 0 28 | 29 | - name: Create Sentry release 30 | uses: getsentry/action-release@v1 31 | env: 32 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 33 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 34 | with: 35 | projects: "sentry-github-actions-app" 36 | environment: production 37 | # We have created releases from the PR, thus, the CLI gets confused finding commits 38 | ignore_missing: true 39 | 40 | docker-build: 41 | name: Docker build 42 | runs-on: ubuntu-latest 43 | timeout-minutes: 10 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v3 47 | 48 | - name: Build 49 | run: | 50 | docker build -t docker-app . 51 | 52 | - name: Test starting 53 | run: | 54 | docker run -d --rm docker-app 55 | docker stop $(docker ps -aq) 56 | 57 | tests: 58 | name: Unit tests 59 | runs-on: ubuntu-latest 60 | timeout-minutes: 10 61 | steps: 62 | - name: Checkout 63 | uses: actions/checkout@v3 64 | 65 | - uses: actions/setup-python@v4 66 | with: 67 | python-version: "3.10" 68 | 69 | - name: Set up 70 | run: | 71 | pip install tox 72 | 73 | - name: Run tests 74 | run: | 75 | tox 76 | 77 | - uses: codecov/codecov-action@v2 78 | with: 79 | verbose: true # optional (default = false) 80 | 81 | pre-commit-checks: 82 | name: Pre-commit checks 83 | runs-on: ubuntu-latest 84 | timeout-minutes: 10 85 | steps: 86 | - name: Checkout 87 | uses: actions/checkout@v3 88 | 89 | - uses: actions/setup-python@v4 90 | with: 91 | python-version: "3.10" 92 | 93 | - name: Set up 94 | run: | 95 | pip install wheel 96 | pip install pre-commit 97 | 98 | - name: Install hooks 99 | run: | 100 | pre-commit install 101 | 102 | - name: Run hooks 103 | run: | 104 | pre-commit run --all-files 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Ignore generated credentials from google-github-actions/auth 132 | gha-creds-*.json 133 | 134 | # Ignore PEM 135 | *.pem 136 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 22.6.0 # Replace by any tag/version: https://github.com/psf/black/tags 4 | hooks: 5 | - id: black 6 | language_version: python3 7 | - repo: https://github.com/python-jsonschema/check-jsonschema 8 | rev: 0.17.1 9 | hooks: 10 | - id: check-github-workflows 11 | - repo: https://github.com/pre-commit/pre-commit-hooks 12 | rev: v4.2.0 13 | hooks: 14 | - id: trailing-whitespace 15 | - id: end-of-file-fixer 16 | - id: check-yaml 17 | - id: debug-statements 18 | # - id: name-tests-test 19 | - id: requirements-txt-fixer 20 | - repo: https://github.com/asottile/reorder_python_imports 21 | rev: v3.1.0 22 | hooks: 23 | - id: reorder-python-imports 24 | args: [--py38-plus, --add-import, "from __future__ import annotations"] 25 | - repo: https://github.com/asottile/pyupgrade 26 | rev: v2.32.1 27 | hooks: 28 | - id: pyupgrade 29 | args: [--py38-plus] 30 | # - repo: https://github.com/PyCQA/flake8 31 | # rev: 4.0.1 32 | # hooks: 33 | # - id: flake8 34 | # - repo: https://github.com/pre-commit/mirrors-mypy 35 | # rev: v0.960 36 | # hooks: 37 | # - id: mypy 38 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": ["esbenp.prettier-vscode"] 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.autoComplete.extraPaths": ["__pypackages__/3.8/lib"], 3 | "python.analysis.extraPaths": ["__pypackages__/3.8/lib"], 4 | "editor.formatOnSave": true, 5 | "editor.formatOnSaveMode": "file", 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | "[javascript]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[ignore]": { 11 | "editor.defaultFormatter": "foxundermoon.shell-format" 12 | }, 13 | "[python]": { 14 | "editor.defaultFormatter": "ms-python.python" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @getsentry/dev-infra 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build stage 2 | FROM python:3.11 AS builder 3 | RUN pip install -U pip setuptools wheel 4 | # Copy files 5 | COPY requirements.frozen.txt /project/ 6 | WORKDIR /project 7 | RUN pip install --no-cache-dir -r requirements.frozen.txt 8 | 9 | # Execution stage 10 | FROM python:3.11 11 | WORKDIR /app 12 | COPY src/ /project/src 13 | # Retrieve packages from build stage 14 | COPY --from=builder /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ 15 | RUN pip install gunicorn==20.1.0 16 | # Source code 17 | COPY src/ /app/src/ 18 | # 1 worker, 4 worker threads should be more than enough. 19 | # --worker-class gthread is automatically set if --threads > 1. 20 | # In my experience this configuration hovers around 100 MB 21 | # baseline (noop app code) memory usage in Cloud Run. 22 | # --timeout 0 disables gunicorn's automatic worker restarting. 23 | # "Workers silent for more than this many seconds are killed and restarted." 24 | # If things get bad you might want to --max-requests, --max-requests-jitter, --workers 2. 25 | CMD ["gunicorn", "--bind", ":8080", "--workers", "1", "--threads", "4", "--timeout", "0", "src.main:app"] 26 | -------------------------------------------------------------------------------- /GithubAppPostInstall.md: -------------------------------------------------------------------------------- 1 | # Github App Post Installation page 2 | 3 | Currently the Github App is only meant to be used for internal use. 4 | 5 | If the app was to be made public and needed post-installation steps they would be listed here. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Armen Zambrano G. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sentry GitHub Actions App 2 | 3 | [![codecov](https://codecov.io/gh/getsentry/sentry-github-actions-app/branch/main/graph/badge.svg?token=LVWHH6NYTF)](https://codecov.io/gh/getsentry/sentry-github-actions-app) 4 | 5 | **NOTE**: If this is a project you would like us to invest in, please let us know in [this issue](https://github.com/getsentry/sentry-github-actions-app/issues/46). You can try this app by following the steps in the "Try it out" section below. 6 | 7 | [This app](https://github.com/apps/sentry-github-app-alpha) allows your organization to instrument GitHub Actions with Sentry. You can use this to get insights of what parts of your CI are slow or failing often. 8 | 9 | It works by listening to GitHub workflow events via a webhook configured in a GitHub App. These events are then stored in Sentry as performance transactions. The best value for this project is when you have [Sentry's Discover](https://docs.sentry.io/product/discover-queries/) and [Dashboards](https://docs.sentry.io/product/dashboards/) in order to slice the data and answer the questions you care about. In a sense, this project turns GitHub's CI into an app which Sentry monitors, thus, you can use many of the features you're already familiar with. 10 | 11 | **NOTE**: The Discover feature is only available on either a Business or Trial Plan. 12 | 13 | **NOTE**: The Dashboards feature is only available on the Team Plan or higher. 14 | 15 | There's likely products out there that can give you insights into GitHub's CI, however, this may be cheaper, it saves you having to add another vendor and your developers are already familiar with using Sentry. 16 | 17 | ## Examples 18 | 19 | This screenshot shows that you can group GitHub Actions through various tags in Sentry's Discover: 20 | 21 | image 22 | 23 | This screenshot shows the transaction view for an individual GitHub Action showing each step of the job: 24 | 25 | image 26 | 27 | ## Features 28 | 29 | Here's a list of benefits you will get if you use this project: 30 | 31 | - Visualize a GitHub job and the breakdown per step 32 | - For instance, it makes it easy to see what percentage of the jobs are dedicated to set up versus running tests 33 | - Create CI alerts 34 | - For instance, if your main branch has a failure rate above a certain threshold 35 | - Notice when `on: schedule` workflows fail which currently GitHub does not make it easy to notice 36 | - Create widgets and dashboards showing your CI data 37 | - For instance you can show: slowest jobs, most failing jobs, jobs requirying re-runs, repos consuming most CI 38 | - Some of the main tags by which you can slice the data: workflow file, repo, branch, commit, job_status, pull_request, run_attempt 39 | 40 | Watch this video showing the features described above: 41 | 42 | https://user-images.githubusercontent.com/44410/187217254-ad7c2eba-f4d4-4a08-9733-54cf92f466ec.mp4 43 | 44 | ## Try it out 45 | 46 | Steps to follow: 47 | 48 | 1. Create a Sentry project to gather your GitHub CI data 49 | 1. You don't need to select a platform in the wizard or any alerts 50 | 2. Find the DSN key under the settings of the project you created 51 | 2. Create a private repo named `.sentry` 52 | 3. Create a file in that repo called `sentry_config.ini` with these contents (adjust your DSN value) 53 | 54 | ```ini 55 | [sentry-github-actions-app] 56 | ; DSN value of the Sentry project you created in step (1) above. 57 | dsn = https://foo@bar.ingest.sentry.io/foobar 58 | ``` 59 | 60 | 4. Install [this GitHub App](https://github.com/apps/sentry-github-app-alpha) 61 | 1. **"All repositories"** - this is the easiest option; all repositories will have telemetry from GitHub Actions sent. 62 | 2. **"Only select repositories"** 63 | 1. Ensure that the `.sentry` private repository (created in step 2 above) is selected 64 | 2. Select any projects that are desired to have telemetry from all containing GitHub Actions sent. 65 | - **Note:** Regardless of which is selected, we only request **workflow jobs** and a **single file** permissions. 66 | 67 | **NOTE**: In other words, we won't be able to access any of your code. 68 | 69 | Give us feedback in [this issue](https://github.com/getsentry/sentry-github-actions-app/issues/46). 70 | 71 | ## Local development 72 | 73 | Prerequisites: 74 | 75 | - ngrok (for local dev) 76 | 77 | Set up: 78 | 79 | ```bash 80 | python3 -m venv .venv 81 | source .venv/bin/activate 82 | pip install wheel 83 | pip install -r requirements.txt -r requirements-dev.txt 84 | ``` 85 | 86 | You can ingest a single job without webhooks by using the cli. For example: 87 | 88 | ```shell 89 | # This is a normal URL of a job on GitHub 90 | python3 cli.py https://github.com/getsentry/sentry/runs/5759197422?check_suite_focus=true 91 | # From test fixture 92 | python3 cli.py tests/fixtures/jobA/job.json 93 | ``` 94 | 95 | Steps to ingest events from a repository: 96 | 97 | - Install ngrok, authenticate and start it up (`ngrok http 5001`) 98 | - Take note of the URL 99 | - Create a GitHub webhook for the repo you want to analyze 100 | - Choose `Workflow jobs` events & make sure to choose `application/json` 101 | - Put Ngrok's URL there 102 | 103 | Table of commands: 104 | 105 | | Command | Description | 106 | | ---------------------------------- | ---------------------------------------------- | 107 | | flask run -p 5001 | Start the Flask app on | 108 | | pre-commit install | Install pre-commit hooks | 109 | | tox | Run tests in isolated environment | 110 | | pytest | Run Python tests | 111 | | pytest --cov=src --cov-report=html | Generate code coverage. | 112 | 113 | ## Sentry staff info 114 | 115 | Google Cloud Build will automatically build a Docker image when the code merges on `main`. Log-in to Google Cloud Run and deploy the latest image. 116 | -------------------------------------------------------------------------------- /cli.py: -------------------------------------------------------------------------------- 1 | # This script only works if you have installed the GH app on an org OR you have created an app with the same permissions 2 | # 3 | # This script can be used to ingest a GH job. To ingest a job point it to the URL showing you the log of a job 4 | # NOTE: Make sure "?check_suite_focus=true" is not included; zsh does not like it 5 | # For instance https://github.com/getsentry/sentry/runs/5759197422?check_suite_focus=true 6 | from __future__ import annotations 7 | 8 | import argparse 9 | import logging 10 | import os 11 | 12 | import requests 13 | 14 | from src.github_app import GithubAppToken 15 | from src.github_sdk import GithubClient 16 | from src.sentry_config import fetch_dsn_for_github_org 17 | from src.web_app_handler import WebAppHandler 18 | 19 | logging.getLogger().setLevel(os.environ.get("LOGGING_LEVEL", "INFO")) 20 | logging.basicConfig() 21 | 22 | 23 | def _fetch_job(url: str) -> tuple(str, dict): 24 | _, _, _, org, repo, _, run_id = url.split("?")[0].split("/") 25 | req = requests.get( 26 | f"https://api.github.com/repos/{org}/{repo}/actions/jobs/{run_id}", 27 | ) 28 | req.raise_for_status() 29 | job = req.json() 30 | return org, job 31 | 32 | 33 | def main() -> int: 34 | parser = argparse.ArgumentParser() 35 | parser.add_argument("url") 36 | parser.add_argument("--installation-id") 37 | args = parser.parse_args() 38 | 39 | org, job = _fetch_job(args.url) 40 | if org != "getsentry": 41 | assert ( 42 | args.installation_id is not None 43 | ), "If you try to use a non-default org, you also need to specify the installation ID." 44 | 45 | # You can have a default installation ID by using an env variable 46 | installation_id = args.installation_id or os.environ["INSTALLATION_ID"] 47 | 48 | web_app = WebAppHandler() 49 | with GithubAppToken(**web_app.config.gh_app._asdict()).get_token( 50 | installation_id 51 | ) as token: 52 | dsn = fetch_dsn_for_github_org(org, token) 53 | client = GithubClient(token=token, dsn=dsn) 54 | client.send_trace(job) 55 | 56 | 57 | if __name__ == "__main__": 58 | raise SystemExit(main()) 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "Github Actions to Sentry app" 3 | version = "0.0.1" 4 | description = "It listens to workflow events and inserts them into Sentry." 5 | authors = [ 6 | {name = "Armen Zambrano G.", email = "armenzg@sentry.io"}, 7 | ] 8 | license-expression = "MIT" 9 | requires-python = ">=3.8" 10 | 11 | [project.urls] 12 | Homepage = "https://github.com/getsentry/github-actions-app" 13 | 14 | [tool.pytest.ini_options] 15 | # -ra: Show extra test summary info: (a)ll except passed 16 | # -q: Decrease verbosity 17 | # --lf: Rerun only the tests that failed at the last run (or all if none failed) 18 | addopts = "-ra -q" 19 | testpaths = [ "tests" ] 20 | 21 | [tool.tox] 22 | legacy_tox_ini = """ 23 | [tox] 24 | envlist = python3.10 25 | skipsdist = true 26 | 27 | [testenv] 28 | deps = 29 | -rrequirements.txt 30 | -rrequirements-dev.txt 31 | commands = 32 | pytest 33 | """ 34 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black>=22.3.0 2 | freezegun>=1.2.1 3 | pre-commit>=2.18.1 4 | pytest>=7.1.1 5 | pytest-cov>=3.0.0 6 | python-dotenv>=0.20.0 7 | responses>=0.20.0 8 | -------------------------------------------------------------------------------- /requirements.frozen.txt: -------------------------------------------------------------------------------- 1 | blinker==1.7.0 2 | cachetools==5.3.2 3 | certifi==2023.11.17 4 | cffi==1.16.0 5 | charset-normalizer==2.0.12 6 | click==8.1.7 7 | cryptography==41.0.7 8 | Flask==2.3.3 9 | google-api-core==2.15.0 10 | google-auth==2.25.2 11 | google-cloud-secret-manager==2.11.0 12 | googleapis-common-protos==1.62.0 13 | grpc-google-iam-v1==0.13.0 14 | grpcio==1.60.0 15 | grpcio-status==1.60.0 16 | idna==3.6 17 | itsdangerous==2.1.2 18 | Jinja2==3.1.2 19 | MarkupSafe==2.1.3 20 | proto-plus==1.23.0 21 | protobuf==4.25.1 22 | pyasn1==0.5.1 23 | pyasn1-modules==0.3.0 24 | pycparser==2.21 25 | PyJWT==2.4.0 26 | requests==2.27.1 27 | rsa==4.9 28 | sentry-sdk==1.5.8 29 | urllib3==1.26.18 30 | Werkzeug==3.0.1 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask==2.3.3 2 | google-cloud-secret-manager==2.11.0 3 | PyJWT[crypto]==2.4.0 4 | requests==2.27.1 5 | sentry-sdk[flask]==1.5.8 6 | -------------------------------------------------------------------------------- /setup.md: -------------------------------------------------------------------------------- 1 | # Set-up 2 | 3 | This page includes information about our deployment. No need to be read by customers installing the Github App. 4 | 5 | ## Backend deployment 6 | 7 | In Sentry.io, we created a project to track errors of the backend itself (`APP_DSN` in section below). 8 | 9 | The app is deployed via GC. The app's deployment is in the webhook url for the Github app. Currently the deployment requires manual intervention. 10 | 11 | Common environment variables: 12 | 13 | - `APP_DSN`: Report your deployment errors to Sentry 14 | - `GH_WEBHOOK_SECRET`: Secret shared between Github's webhook and your app 15 | - Create a secret with `python3 -c 'import secrets; print(secrets.token_urlsafe(20))'` on your command line 16 | - `LOGGING_LEVEL` (optional): To set the verbosity of Python's logging (defaults to INFO) 17 | 18 | Github App specific variables: 19 | 20 | - `GH_APP_ID` - Github App ID 21 | - When you create the Github App, you will see the value listed in the page 22 | - `GH_APP_PRIVATE_KEY` - Private key 23 | - When you load the Github App page, at the bottom of the page under "Private Keys" select "Generate a private key" 24 | - A .pem file will be downloaded locally. 25 | - Convert it into a single line value by using base64 (`base64 -i path_to_pem_file`) and delete it 26 | 27 | For local development, you need to make the App's webhook point to your ngrok set up. You should create a new private key (a .pem file that gets automatically downloaded when generated) for your local development and do not forget to delete the private key when you are done. 28 | 29 | ## The Github App 30 | 31 | **NOTE**: Only Sentry Github Admins can make changes to the app 32 | 33 | Configuration taken after creating the app: 34 | 35 | - Configure the Webhook URL to point to the backend deployment 36 | - Set the Webhook Secret to be the same as the backend deployment 37 | - Set the following permissions for the app: 38 | - Actions: Workflows, workflow runs and artifacts -> Read-only 39 | - Singe File: sentry_config.ini 40 | - Subscribe to events: 41 | - [Workflow job](https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#workflow_job) 42 | 43 | After completing the creation of the app you will need to make few more changes: 44 | 45 | - Click on `Generate a private key` 46 | - Run `base64 -i ` 47 | - Add to the deployment the variable `GH_APP_PRIVATE_KEY` 48 | - Delete the file just downloaded 49 | - Add to the deployment the variable `GH_APP_ID` 50 | - Select "Install App" and install the app on your Github org 51 | - Grant access to "All Repositories" 52 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/sentry-github-actions-app/1d039c3628f33bbbb19b63643dae32721e7c105d/src/__init__.py -------------------------------------------------------------------------------- /src/github_app.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the logic to support running the app as a Github App 3 | """ 4 | from __future__ import annotations 5 | 6 | import contextlib 7 | import time 8 | from typing import Generator 9 | 10 | import jwt 11 | import requests 12 | 13 | 14 | class GithubAppToken: 15 | def __init__(self, private_key, app_id) -> None: 16 | self.headers = self.get_authentication_header(private_key, app_id) 17 | 18 | # From docs: Installation access tokens have the permissions 19 | # configured by the GitHub App and expire after one hour. 20 | @contextlib.contextmanager 21 | def get_token(self, installation_id: int) -> Generator[str, None, None]: 22 | req = requests.post( 23 | url=f"https://api.github.com/app/installations/{installation_id}/access_tokens", 24 | headers=self.headers, 25 | ) 26 | req.raise_for_status() 27 | resp = req.json() 28 | try: 29 | # This token expires in an hour 30 | yield resp["token"] 31 | finally: 32 | requests.delete( 33 | "https://api.github.com/installation/token", 34 | headers={"Authorization": f"token {resp['token']}"}, 35 | ) 36 | 37 | def get_jwt_token(self, private_key, app_id): 38 | payload = { 39 | # issued at time, 60 seconds in the past to allow for clock drift 40 | "iat": int(time.time()) - 60, 41 | # JWT expiration time (5 minutes maximum) 42 | "exp": int(time.time()) + 5 * 60, 43 | # GitHub App's identifier 44 | "iss": app_id, 45 | } 46 | return jwt.encode(payload, private_key, algorithm="RS256") 47 | 48 | def get_authentication_header(self, private_key, app_id): 49 | jwt_token = self.get_jwt_token(private_key, app_id) 50 | return { 51 | "Accept": "application/vnd.github.v3+json", 52 | "Authorization": f"Bearer {jwt_token}", 53 | } 54 | -------------------------------------------------------------------------------- /src/github_sdk.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import gzip 4 | import hashlib 5 | import io 6 | import logging 7 | import uuid 8 | from datetime import datetime 9 | 10 | import requests 11 | from sentry_sdk.envelope import Envelope 12 | from sentry_sdk.utils import format_timestamp 13 | 14 | 15 | class GithubSentryError(Exception): 16 | pass 17 | 18 | 19 | def get_uuid(): 20 | return uuid.uuid4().hex 21 | 22 | 23 | def get_uuid_from_string(input_string): 24 | hash_object = hashlib.sha256(input_string.encode()) 25 | hash_value = hash_object.hexdigest() 26 | return uuid.UUID(hash_value[:32]).hex 27 | 28 | 29 | class GithubClient: 30 | # This transform GH jobs conclusion keywords to Sentry performance status 31 | github_status_trace_status = {"success": "ok", "failure": "internal_error"} 32 | 33 | def __init__(self, token, dsn, dry_run=False) -> None: 34 | self.token = token 35 | self.dry_run = dry_run 36 | if dsn: 37 | base_uri, project_id = dsn.rsplit("/", 1) 38 | self.sentry_key = base_uri.rsplit("@")[0].rsplit("https://")[1] 39 | # '{BASE_URI}/api/{PROJECT_ID}/{ENDPOINT}/' 40 | self.sentry_project_url = f"{base_uri}/api/{project_id}/envelope/" 41 | 42 | def _fetch_github(self, url): 43 | headers = {"Authorization": f"token {self.token}"} 44 | 45 | req = requests.get(url, headers=headers) 46 | req.raise_for_status() 47 | return req 48 | 49 | def _get_extra_metadata(self, job): 50 | # XXX: This is the slowest call 51 | runs = self._fetch_github(job["run_url"]).json() 52 | workflow = self._fetch_github(runs["workflow_url"]).json() 53 | repo = runs["repository"]["full_name"] 54 | meta = { 55 | # "workflow_name": workflow["name"], 56 | "author": runs["head_commit"]["author"], 57 | # https://getsentry.atlassian.net/browse/TET-22 58 | # Tags are not linkified externally, plain text data can be selected in browsers and opened 59 | "data": { 60 | "job": job["html_url"], 61 | }, 62 | "tags": { 63 | # e.g. success, failure, skipped 64 | "job_status": job["conclusion"], 65 | "branch": runs["head_branch"], 66 | "commit": runs["head_sha"], 67 | "repo": repo, 68 | "run_attempt": runs["run_attempt"], # Rerunning a job 69 | # It allows querying jobs within the same workflow (e.g. foo.yml) 70 | "workflow": workflow["path"].rsplit("/")[-1], 71 | }, 72 | } 73 | if runs.get("pull_requests"): 74 | pr_number = runs["pull_requests"][0]["number"] 75 | meta["data"]["pr"] = f"https://github.com/{repo}/pull/{pr_number}" 76 | meta["tags"]["pull_request"] = pr_number 77 | 78 | return meta 79 | 80 | # Documentation about traces, transactions and spans 81 | # https://docs.sentry.io/product/sentry-basics/tracing/distributed-tracing/#traces 82 | # https://develop.sentry.dev/sdk/performance/ 83 | def _generate_trace(self, job): 84 | meta = self._get_extra_metadata(job) 85 | transaction = _base_transaction(job) 86 | transaction["user"] = meta["author"] 87 | transaction["tags"] = meta["tags"] 88 | transaction["contexts"]["trace"]["data"] = meta["data"] 89 | 90 | # Transactions have name, spans don't. 91 | transaction["contexts"]["trace"]["op"] = job["name"] 92 | transaction["contexts"]["trace"]["description"] = job["name"] 93 | transaction["contexts"]["trace"][ 94 | "status" 95 | ] = self.github_status_trace_status.get(job["conclusion"], "unimplemented") 96 | transaction["spans"] = _generate_spans( 97 | job["steps"], 98 | transaction["contexts"]["trace"]["span_id"], 99 | transaction["contexts"]["trace"]["trace_id"], 100 | ) 101 | return transaction 102 | 103 | def _send_envelope(self, trace): 104 | if self.dry_run: 105 | return 106 | envelope = Envelope() 107 | envelope.add_transaction(trace) 108 | now = datetime.utcnow() 109 | 110 | headers = { 111 | "event_id": get_uuid(), # Does this have to match anything? 112 | "sent_at": format_timestamp(now), 113 | "Content-Type": "application/x-sentry-envelope", 114 | "Content-Encoding": "gzip", 115 | "X-Sentry-Auth": f"Sentry sentry_key={self.sentry_key}," 116 | + f"sentry_client=gha-sentry/0.0.1,sentry_timestamp={now}," 117 | + "sentry_version=7", 118 | } 119 | 120 | body = io.BytesIO() 121 | with gzip.GzipFile(fileobj=body, mode="w") as f: 122 | envelope.serialize_into(f) 123 | 124 | req = requests.post( 125 | self.sentry_project_url, 126 | data=body.getvalue(), 127 | headers=headers, 128 | ) 129 | req.raise_for_status() 130 | return req 131 | 132 | def send_trace(self, job): 133 | # This can happen when the workflow is skipped and there are no steps 134 | if job["conclusion"] == "skipped": 135 | logging.info( 136 | f"We are ignoring '{job['name']}' because it was skipped -> {job['html_url']}", 137 | ) 138 | return 139 | trace = self._generate_trace(job) 140 | if trace: 141 | return self._send_envelope(trace) 142 | 143 | 144 | def _base_transaction(job): 145 | return { 146 | "event_id": get_uuid(), 147 | # The distinctive feature of a Transaction is type: "transaction". 148 | "type": "transaction", 149 | "transaction": job["name"], 150 | "contexts": { 151 | "trace": { 152 | "span_id": get_uuid()[:16], 153 | # This trace id is basically just a hash from run_id and run_attempt turned into a uuid. We use it to trace jobs within a workflow. 154 | # `run_id` identifiess a particular workflow run and does not increment when rerunning jobs. This is why we also use `run_attempt` in the job. 155 | # https://docs.github.com/en/actions/learn-github-actions/contexts 156 | # https://docs.github.com/en/webhooks/webhook-events-and-payloads#workflow_job 157 | "trace_id": get_uuid_from_string( 158 | "run_id:" 159 | + str(job["run_id"]) 160 | + "run_attempt:" 161 | + str(job["run_attempt"]) 162 | ), 163 | "type": "trace", 164 | }, 165 | }, 166 | "user": {}, 167 | # When ingesting old data during development (e.g. using fixtures), Sentry's UI will 168 | # show an error for transactions with "Clock drift detected in SDK"; It is harmeless. 169 | "start_timestamp": job["started_at"], 170 | "timestamp": job["completed_at"], 171 | } 172 | 173 | 174 | # https://develop.sentry.dev/sdk/event-payloads/span/ 175 | def _generate_spans(steps, parent_span_id, trace_id): 176 | spans = [] 177 | for step in steps: 178 | try: 179 | spans.append( 180 | { 181 | "op": step["name"], 182 | "name": step["name"], 183 | "parent_span_id": parent_span_id, 184 | "span_id": get_uuid()[:16], 185 | "start_timestamp": step["started_at"], 186 | "timestamp": step["completed_at"], 187 | "trace_id": trace_id, 188 | }, 189 | ) 190 | except Exception as e: 191 | logging.exception(e) 192 | return spans 193 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | 6 | import sentry_sdk 7 | from flask import abort 8 | from flask import Flask 9 | from flask import jsonify 10 | from flask import request 11 | from sentry_sdk import capture_exception 12 | from sentry_sdk.integrations.flask import FlaskIntegration 13 | 14 | from .web_app_handler import WebAppHandler 15 | 16 | APP_DSN = os.environ.get("APP_DSN") 17 | if APP_DSN: 18 | # This tracks errors and performance of the app itself rather than GH workflows 19 | sentry_sdk.init( 20 | dsn=APP_DSN, 21 | integrations=[FlaskIntegration()], 22 | # Set traces_sample_rate to 1.0 to capture 100% 23 | # of transactions for performance monitoring. 24 | # We recommend adjusting this value in production. 25 | traces_sample_rate=1.0, 26 | environment=os.environ.get("FLASK_ENV", "production"), 27 | ) 28 | 29 | LOGGING_LEVEL = os.environ.get("LOGGING_LEVEL", "INFO") 30 | # Set the logging level for all loggers (e.g. requests) 31 | logging.getLogger().setLevel(LOGGING_LEVEL) 32 | logging.basicConfig() 33 | 34 | # Logger for this module 35 | logger = logging.getLogger(__name__) 36 | logger.setLevel(LOGGING_LEVEL) 37 | logger.info("App logging is working.") 38 | 39 | handler = WebAppHandler() 40 | 41 | app = Flask(__name__) 42 | 43 | 44 | @app.route("/", methods=["POST"]) 45 | def main(): 46 | if not handler.valid_signature(request.data, request.headers): 47 | abort( 48 | 400, 49 | "The secret you are using on your Github webhook does not match this app's secret.", 50 | ) 51 | 52 | # Top-level crash preventing try block 53 | try: 54 | reason, http_code = handler.handle_event(request.json, request.headers) 55 | return jsonify({"reason": reason}), http_code 56 | except Exception as e: 57 | logger.exception(e) 58 | capture_exception(e) 59 | return jsonify({"reason": "There was an error."}), 500 60 | 61 | 62 | if os.environ.get("FLASK_ENV") == "development": 63 | 64 | @app.route("/debug-sentry") 65 | def trigger_error(): 66 | try: 67 | 1 / 0 68 | except Exception as e: 69 | # Report it to Sentry 70 | capture_exception(e) 71 | # Let Flask handle the rest 72 | raise e 73 | -------------------------------------------------------------------------------- /src/sentry_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | from configparser import ConfigParser 5 | from functools import lru_cache 6 | 7 | import requests 8 | 9 | SENTRY_CONFIG_API_URL = ( 10 | "https://api.github.com/repos/{owner}/.sentry/contents/sentry_config.ini" 11 | ) 12 | 13 | 14 | def fetch_dsn_for_github_org(org: str, token: str) -> str: 15 | # Using the GH app token allows fetching the file in a private repo 16 | headers = { 17 | "Accept": "application/vnd.github+json", 18 | "Authorization": f"token {token}", 19 | } 20 | api_url = SENTRY_CONFIG_API_URL.replace("{owner}", org) 21 | # - Get meta about sentry_config.ini file 22 | resp = requests.get(api_url, headers=headers) 23 | resp.raise_for_status() 24 | meta = resp.json() 25 | 26 | if meta["type"] != "file": 27 | # XXX: custom error 28 | raise Exception(meta["type"]) 29 | 30 | assert meta["encoding"] == "base64", meta["encoding"] 31 | file_contents = base64.b64decode(meta["content"]).decode() 32 | 33 | # - Read ini file and assertions 34 | cp = ConfigParser() 35 | cp.read_string(file_contents) 36 | return cp.get("sentry-github-actions-app", "dsn") 37 | -------------------------------------------------------------------------------- /src/web_app_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import hmac 5 | import logging 6 | import os 7 | from typing import NamedTuple 8 | 9 | from .github_app import GithubAppToken 10 | from .github_sdk import GithubClient 11 | from src.sentry_config import fetch_dsn_for_github_org 12 | 13 | LOGGING_LEVEL = os.environ.get("LOGGING_LEVEL", logging.INFO) 14 | logger = logging.getLogger(__name__) 15 | logger.setLevel(LOGGING_LEVEL) 16 | 17 | 18 | class WebAppHandler: 19 | def __init__(self, dry_run=False): 20 | self.config = init_config() 21 | self.dry_run = dry_run 22 | 23 | def handle_event(self, data, headers): 24 | # We return 200 to make webhook not turn red since everything got processed well 25 | http_code = 200 26 | reason = "OK" 27 | 28 | if headers["X-GitHub-Event"] != "workflow_job": 29 | reason = "Event not supported." 30 | elif data["action"] != "completed": 31 | reason = "We cannot do anything with this workflow state." 32 | else: 33 | # For now, this simplifies testing 34 | if self.dry_run: 35 | return reason, http_code 36 | 37 | installation_id = data["installation"]["id"] 38 | org = data["repository"]["owner"]["login"] 39 | 40 | # We are executing in Github App mode 41 | if self.config.gh_app: 42 | with GithubAppToken(**self.config.gh_app._asdict()).get_token( 43 | installation_id 44 | ) as token: 45 | # Once the Sentry org has a .sentry repo we can remove the DSN from the deployment 46 | dsn = fetch_dsn_for_github_org(org, token) 47 | client = GithubClient( 48 | token=token, 49 | dsn=dsn, 50 | dry_run=self.dry_run, 51 | ) 52 | client.send_trace(data["workflow_job"]) 53 | else: 54 | # Once the Sentry org has a .sentry repo we can remove the DSN from the deployment 55 | dsn = fetch_dsn_for_github_org(org, token) 56 | client = GithubClient( 57 | token=self.config.gh.token, 58 | dsn=dsn, 59 | dry_run=self.dry_run, 60 | ) 61 | client.send_trace(data["workflow_job"]) 62 | 63 | return reason, http_code 64 | 65 | def valid_signature(self, body, headers): 66 | if not self.config.gh.webhook_secret: 67 | return True 68 | else: 69 | signature = headers["X-Hub-Signature-256"].replace("sha256=", "") 70 | body_signature = hmac.new( 71 | self.config.gh.webhook_secret.encode(), 72 | msg=body, 73 | digestmod="sha256", 74 | ).hexdigest() 75 | return hmac.compare_digest(body_signature, signature) 76 | 77 | 78 | class GithubAppConfig(NamedTuple): 79 | app_id: int 80 | private_key: str 81 | 82 | 83 | class GitHubConfig(NamedTuple): 84 | webhook_secret: str | None 85 | token: str | None 86 | 87 | 88 | class Config(NamedTuple): 89 | gh_app: GithubAppConfig | None 90 | gh: GitHubConfig 91 | 92 | 93 | def get_gh_app_private_key(): 94 | private_key = None 95 | # K_SERVICE is a reserved variable for Google Cloud services 96 | if os.environ.get("K_SERVICE") and not os.environ.get("GH_APP_PRIVATE_KEY"): 97 | # XXX: Put in here since it currently affects test execution 98 | # ImportError: dlopen(/Users/armenzg/code/github-actions-app/.venv/lib/python3.10/site-packages/grpc/_cython/cygrpc.cpython-310-darwin.so, 0x0002): tried: '/Users/armenzg/code/github-actions-app/.venv/lib/python3.10/site-packages/grpc/_cython/cygrpc.cpython-310-darwin.so' 99 | # (mach-o file, but is an incompatible architecture (have 'x86_64', need 'arm64e')) 100 | from google.cloud import secretmanager 101 | 102 | gcp_client = secretmanager.SecretManagerServiceClient() 103 | uri = ( 104 | f"projects/sentry-dev-tooling/secrets/SentryGithubAppPrivateKey/versions/1" 105 | ) 106 | 107 | logger.info(f"Grabbing secret from {uri}") 108 | private_key = base64.b64decode( 109 | gcp_client.access_secret_version( 110 | name=uri, 111 | ).payload.data.decode("UTF-8"), 112 | ) 113 | else: 114 | # This block only applies for development since we are not executing on GCP 115 | private_key = base64.b64decode(os.environ["GH_APP_PRIVATE_KEY"]) 116 | return private_key 117 | 118 | 119 | def init_config(): 120 | gh_app = None 121 | try: 122 | # This variable is the key to enabling Github App mode or not 123 | if os.environ.get("GH_APP_ID"): 124 | private_key = get_gh_app_private_key() 125 | gh_app = GithubAppConfig( 126 | app_id=os.environ["GH_APP_ID"], 127 | private_key=private_key, 128 | ) 129 | except Exception as e: 130 | logger.exception(e) 131 | logger.warning( 132 | "We have failed to load the private key, however, we will fallback to the PAT method.", 133 | ) 134 | 135 | return Config( 136 | gh_app, 137 | GitHubConfig( 138 | # This token is a PAT 139 | token=os.environ.get("GH_TOKEN"), 140 | webhook_secret=os.environ.get("GH_WEBHOOK_SECRET"), 141 | ), 142 | ) 143 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/sentry-github-actions-app/1d039c3628f33bbbb19b63643dae32721e7c105d/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | 5 | import pytest 6 | 7 | 8 | @pytest.fixture 9 | def jobA_job(): 10 | with open("tests/fixtures/jobA/job.json") as f: 11 | return json.load(f) 12 | 13 | 14 | @pytest.fixture 15 | def jobA_runs(): 16 | with open("tests/fixtures/jobA/runs.json") as f: 17 | return json.load(f) 18 | 19 | 20 | @pytest.fixture 21 | def jobA_workflow(): 22 | with open("tests/fixtures/jobA/workflow.json") as f: 23 | return json.load(f) 24 | 25 | 26 | @pytest.fixture 27 | def skipped_workflow(): 28 | with open("tests/fixtures/skipped_workflow.json") as f: 29 | return json.load(f) 30 | 31 | 32 | @pytest.fixture 33 | def jobA_trace(): 34 | with open("tests/fixtures/jobA/trace.json") as f: 35 | return json.load(f) 36 | 37 | 38 | @pytest.fixture 39 | def uuid_list(): 40 | return [ 41 | "5ae279acd9824cbfa85042013cfbf8b7", 42 | "a401d83c7ec0495f82a8da8d9a389f5b", 43 | "4d4d3477c624836b1b3a3729a7de688a", 44 | "0726fb4a2341477cbfa36ebd830bd8e3", 45 | "a7776ed12daa449bb0642e9ea1bb4152", 46 | "6e147ff372f9498abb5749a1210d8e0a", 47 | "a401d83c7ec0495f82a8da8d9a389f5b", 48 | "a401d83c7ec0495f82a8da8d9a389f5b", 49 | ] 50 | 51 | 52 | @pytest.fixture 53 | def webhook_event(): 54 | with open("tests/fixtures/webhook_event.json") as f: 55 | return json.load(f) 56 | -------------------------------------------------------------------------------- /tests/fixtures/jobA/job.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 5857527450, 3 | "run_id": 2104746951, 4 | "run_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951", 5 | "run_attempt": 1, 6 | "node_id": "CR_kwDOAA1TcM8AAAABXSLGmg", 7 | "head_sha": "fd976218a94d8a0cd203c711ea7dbe68c573ef4d", 8 | "url": "https://api.github.com/repos/getsentry/sentry/actions/jobs/5857527450", 9 | "html_url": "https://github.com/getsentry/sentry/runs/5857527450?check_suite_focus=true", 10 | "status": "completed", 11 | "conclusion": "success", 12 | "started_at": "2022-04-06T19:52:17Z", 13 | "completed_at": "2022-04-06T20:05:37Z", 14 | "name": "frontend tests (0)", 15 | "steps": [ 16 | { 17 | "name": "Set up job", 18 | "status": "completed", 19 | "conclusion": "success", 20 | "number": 1, 21 | "started_at": "2022-04-06T19:52:16.000Z", 22 | "completed_at": "2022-04-06T19:52:20.000Z" 23 | }, 24 | { 25 | "name": "Pull ghcr.io/getsentry/action-html-to-image:latest", 26 | "status": "completed", 27 | "conclusion": "success", 28 | "number": 2, 29 | "started_at": "2022-04-06T19:52:20.000Z", 30 | "completed_at": "2022-04-06T19:52:58.000Z" 31 | }, 32 | { 33 | "name": "Post Checkout sentry", 34 | "status": "completed", 35 | "conclusion": "success", 36 | "number": 26, 37 | "started_at": "2022-04-06T20:05:37.000Z", 38 | "completed_at": "2022-04-06T20:05:35.000Z" 39 | }, 40 | { 41 | "name": "Complete job", 42 | "status": "completed", 43 | "conclusion": "success", 44 | "number": 28, 45 | "started_at": "2022-04-06T20:05:35.000Z", 46 | "completed_at": "2022-04-06T20:05:35.000Z" 47 | } 48 | ], 49 | "check_run_url": "https://api.github.com/repos/getsentry/sentry/check-runs/5857527450", 50 | "labels": ["ubuntu-20.04"], 51 | "runner_id": 82, 52 | "runner_name": "GitHub Actions 42", 53 | "runner_group_id": 2, 54 | "runner_group_name": "GitHub Actions" 55 | } 56 | -------------------------------------------------------------------------------- /tests/fixtures/jobA/runs.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2104746951, 3 | "name": "acceptance", 4 | "node_id": "WFR_kwLOAA1TcM59c-PH", 5 | "head_branch": "ahmed/add-naming-layer", 6 | "head_sha": "fd976218a94d8a0cd203c711ea7dbe68c573ef4d", 7 | "run_number": 53142, 8 | "event": "pull_request", 9 | "status": "completed", 10 | "conclusion": "success", 11 | "workflow_id": 1174556, 12 | "check_suite_id": 5961050594, 13 | "check_suite_node_id": "CS_kwDOAA1TcM8AAAABY05p4g", 14 | "url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951", 15 | "html_url": "https://github.com/getsentry/sentry/actions/runs/2104746951", 16 | "pull_requests": [ 17 | { 18 | "url": "https://api.github.com/repos/getsentry/sentry/pulls/33347", 19 | "id": 901742401, 20 | "number": 33347, 21 | "head": { 22 | "ref": "ahmed/add-naming-layer", 23 | "sha": "fd976218a94d8a0cd203c711ea7dbe68c573ef4d", 24 | "repo": { 25 | "id": 873328, 26 | "url": "https://api.github.com/repos/getsentry/sentry", 27 | "name": "sentry" 28 | } 29 | }, 30 | "base": { 31 | "ref": "master", 32 | "sha": "4cf18bee34fcb9a0e38b6f1b7dd52323dd6ab45f", 33 | "repo": { 34 | "id": 873328, 35 | "url": "https://api.github.com/repos/getsentry/sentry", 36 | "name": "sentry" 37 | } 38 | } 39 | } 40 | ], 41 | "created_at": "2022-04-06T19:52:00Z", 42 | "updated_at": "2022-04-06T20:14:17Z", 43 | "actor": { 44 | "login": "ahmedetefy", 45 | "id": 10855986, 46 | "node_id": "MDQ6VXNlcjEwODU1OTg2", 47 | "avatar_url": "https://avatars.githubusercontent.com/u/10855986?v=4", 48 | "gravatar_id": "", 49 | "url": "https://api.github.com/users/ahmedetefy", 50 | "html_url": "https://github.com/ahmedetefy", 51 | "followers_url": "https://api.github.com/users/ahmedetefy/followers", 52 | "following_url": "https://api.github.com/users/ahmedetefy/following{/other_user}", 53 | "gists_url": "https://api.github.com/users/ahmedetefy/gists{/gist_id}", 54 | "starred_url": "https://api.github.com/users/ahmedetefy/starred{/owner}{/repo}", 55 | "subscriptions_url": "https://api.github.com/users/ahmedetefy/subscriptions", 56 | "organizations_url": "https://api.github.com/users/ahmedetefy/orgs", 57 | "repos_url": "https://api.github.com/users/ahmedetefy/repos", 58 | "events_url": "https://api.github.com/users/ahmedetefy/events{/privacy}", 59 | "received_events_url": "https://api.github.com/users/ahmedetefy/received_events", 60 | "type": "User", 61 | "site_admin": false 62 | }, 63 | "run_attempt": 1, 64 | "run_started_at": "2022-04-06T19:52:00Z", 65 | "triggering_actor": { 66 | "login": "ahmedetefy", 67 | "id": 10855986, 68 | "node_id": "MDQ6VXNlcjEwODU1OTg2", 69 | "avatar_url": "https://avatars.githubusercontent.com/u/10855986?v=4", 70 | "gravatar_id": "", 71 | "url": "https://api.github.com/users/ahmedetefy", 72 | "html_url": "https://github.com/ahmedetefy", 73 | "followers_url": "https://api.github.com/users/ahmedetefy/followers", 74 | "following_url": "https://api.github.com/users/ahmedetefy/following{/other_user}", 75 | "gists_url": "https://api.github.com/users/ahmedetefy/gists{/gist_id}", 76 | "starred_url": "https://api.github.com/users/ahmedetefy/starred{/owner}{/repo}", 77 | "subscriptions_url": "https://api.github.com/users/ahmedetefy/subscriptions", 78 | "organizations_url": "https://api.github.com/users/ahmedetefy/orgs", 79 | "repos_url": "https://api.github.com/users/ahmedetefy/repos", 80 | "events_url": "https://api.github.com/users/ahmedetefy/events{/privacy}", 81 | "received_events_url": "https://api.github.com/users/ahmedetefy/received_events", 82 | "type": "User", 83 | "site_admin": false 84 | }, 85 | "jobs_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951/jobs", 86 | "logs_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951/logs", 87 | "check_suite_url": "https://api.github.com/repos/getsentry/sentry/check-suites/5961050594", 88 | "artifacts_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951/artifacts", 89 | "cancel_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951/cancel", 90 | "rerun_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951/rerun", 91 | "previous_attempt_url": null, 92 | "workflow_url": "https://api.github.com/repos/getsentry/sentry/actions/workflows/1174556", 93 | "head_commit": { 94 | "id": "fd976218a94d8a0cd203c711ea7dbe68c573ef4d", 95 | "tree_id": "e12e240849bd390ab3fb7f54f178e11446d29ddd", 96 | "message": "Clean up tests", 97 | "timestamp": "2022-04-06T19:51:41Z", 98 | "author": { 99 | "name": "Ahmed Etefy", 100 | "email": "ahmed.etefy12@gmail.com" 101 | }, 102 | "committer": { 103 | "name": "Ahmed Etefy", 104 | "email": "ahmed.etefy12@gmail.com" 105 | } 106 | }, 107 | "repository": { 108 | "id": 873328, 109 | "node_id": "MDEwOlJlcG9zaXRvcnk4NzMzMjg=", 110 | "name": "sentry", 111 | "full_name": "getsentry/sentry", 112 | "private": false, 113 | "owner": { 114 | "login": "getsentry", 115 | "id": 1396951, 116 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjEzOTY5NTE=", 117 | "avatar_url": "https://avatars.githubusercontent.com/u/1396951?v=4", 118 | "gravatar_id": "", 119 | "url": "https://api.github.com/users/getsentry", 120 | "html_url": "https://github.com/getsentry", 121 | "followers_url": "https://api.github.com/users/getsentry/followers", 122 | "following_url": "https://api.github.com/users/getsentry/following{/other_user}", 123 | "gists_url": "https://api.github.com/users/getsentry/gists{/gist_id}", 124 | "starred_url": "https://api.github.com/users/getsentry/starred{/owner}{/repo}", 125 | "subscriptions_url": "https://api.github.com/users/getsentry/subscriptions", 126 | "organizations_url": "https://api.github.com/users/getsentry/orgs", 127 | "repos_url": "https://api.github.com/users/getsentry/repos", 128 | "events_url": "https://api.github.com/users/getsentry/events{/privacy}", 129 | "received_events_url": "https://api.github.com/users/getsentry/received_events", 130 | "type": "Organization", 131 | "site_admin": false 132 | }, 133 | "html_url": "https://github.com/getsentry/sentry", 134 | "description": "Sentry is cross-platform application monitoring, with a focus on error reporting.", 135 | "fork": false, 136 | "url": "https://api.github.com/repos/getsentry/sentry", 137 | "forks_url": "https://api.github.com/repos/getsentry/sentry/forks", 138 | "keys_url": "https://api.github.com/repos/getsentry/sentry/keys{/key_id}", 139 | "collaborators_url": "https://api.github.com/repos/getsentry/sentry/collaborators{/collaborator}", 140 | "teams_url": "https://api.github.com/repos/getsentry/sentry/teams", 141 | "hooks_url": "https://api.github.com/repos/getsentry/sentry/hooks", 142 | "issue_events_url": "https://api.github.com/repos/getsentry/sentry/issues/events{/number}", 143 | "events_url": "https://api.github.com/repos/getsentry/sentry/events", 144 | "assignees_url": "https://api.github.com/repos/getsentry/sentry/assignees{/user}", 145 | "branches_url": "https://api.github.com/repos/getsentry/sentry/branches{/branch}", 146 | "tags_url": "https://api.github.com/repos/getsentry/sentry/tags", 147 | "blobs_url": "https://api.github.com/repos/getsentry/sentry/git/blobs{/sha}", 148 | "git_tags_url": "https://api.github.com/repos/getsentry/sentry/git/tags{/sha}", 149 | "git_refs_url": "https://api.github.com/repos/getsentry/sentry/git/refs{/sha}", 150 | "trees_url": "https://api.github.com/repos/getsentry/sentry/git/trees{/sha}", 151 | "statuses_url": "https://api.github.com/repos/getsentry/sentry/statuses/{sha}", 152 | "languages_url": "https://api.github.com/repos/getsentry/sentry/languages", 153 | "stargazers_url": "https://api.github.com/repos/getsentry/sentry/stargazers", 154 | "contributors_url": "https://api.github.com/repos/getsentry/sentry/contributors", 155 | "subscribers_url": "https://api.github.com/repos/getsentry/sentry/subscribers", 156 | "subscription_url": "https://api.github.com/repos/getsentry/sentry/subscription", 157 | "commits_url": "https://api.github.com/repos/getsentry/sentry/commits{/sha}", 158 | "git_commits_url": "https://api.github.com/repos/getsentry/sentry/git/commits{/sha}", 159 | "comments_url": "https://api.github.com/repos/getsentry/sentry/comments{/number}", 160 | "issue_comment_url": "https://api.github.com/repos/getsentry/sentry/issues/comments{/number}", 161 | "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/{+path}", 162 | "compare_url": "https://api.github.com/repos/getsentry/sentry/compare/{base}...{head}", 163 | "merges_url": "https://api.github.com/repos/getsentry/sentry/merges", 164 | "archive_url": "https://api.github.com/repos/getsentry/sentry/{archive_format}{/ref}", 165 | "downloads_url": "https://api.github.com/repos/getsentry/sentry/downloads", 166 | "issues_url": "https://api.github.com/repos/getsentry/sentry/issues{/number}", 167 | "pulls_url": "https://api.github.com/repos/getsentry/sentry/pulls{/number}", 168 | "milestones_url": "https://api.github.com/repos/getsentry/sentry/milestones{/number}", 169 | "notifications_url": "https://api.github.com/repos/getsentry/sentry/notifications{?since,all,participating}", 170 | "labels_url": "https://api.github.com/repos/getsentry/sentry/labels{/name}", 171 | "releases_url": "https://api.github.com/repos/getsentry/sentry/releases{/id}", 172 | "deployments_url": "https://api.github.com/repos/getsentry/sentry/deployments" 173 | }, 174 | "head_repository": { 175 | "id": 873328, 176 | "node_id": "MDEwOlJlcG9zaXRvcnk4NzMzMjg=", 177 | "name": "sentry", 178 | "full_name": "getsentry/sentry", 179 | "private": false, 180 | "owner": { 181 | "login": "getsentry", 182 | "id": 1396951, 183 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjEzOTY5NTE=", 184 | "avatar_url": "https://avatars.githubusercontent.com/u/1396951?v=4", 185 | "gravatar_id": "", 186 | "url": "https://api.github.com/users/getsentry", 187 | "html_url": "https://github.com/getsentry", 188 | "followers_url": "https://api.github.com/users/getsentry/followers", 189 | "following_url": "https://api.github.com/users/getsentry/following{/other_user}", 190 | "gists_url": "https://api.github.com/users/getsentry/gists{/gist_id}", 191 | "starred_url": "https://api.github.com/users/getsentry/starred{/owner}{/repo}", 192 | "subscriptions_url": "https://api.github.com/users/getsentry/subscriptions", 193 | "organizations_url": "https://api.github.com/users/getsentry/orgs", 194 | "repos_url": "https://api.github.com/users/getsentry/repos", 195 | "events_url": "https://api.github.com/users/getsentry/events{/privacy}", 196 | "received_events_url": "https://api.github.com/users/getsentry/received_events", 197 | "type": "Organization", 198 | "site_admin": false 199 | }, 200 | "html_url": "https://github.com/getsentry/sentry", 201 | "description": "Sentry is cross-platform application monitoring, with a focus on error reporting.", 202 | "fork": false, 203 | "url": "https://api.github.com/repos/getsentry/sentry", 204 | "forks_url": "https://api.github.com/repos/getsentry/sentry/forks", 205 | "keys_url": "https://api.github.com/repos/getsentry/sentry/keys{/key_id}", 206 | "collaborators_url": "https://api.github.com/repos/getsentry/sentry/collaborators{/collaborator}", 207 | "teams_url": "https://api.github.com/repos/getsentry/sentry/teams", 208 | "hooks_url": "https://api.github.com/repos/getsentry/sentry/hooks", 209 | "issue_events_url": "https://api.github.com/repos/getsentry/sentry/issues/events{/number}", 210 | "events_url": "https://api.github.com/repos/getsentry/sentry/events", 211 | "assignees_url": "https://api.github.com/repos/getsentry/sentry/assignees{/user}", 212 | "branches_url": "https://api.github.com/repos/getsentry/sentry/branches{/branch}", 213 | "tags_url": "https://api.github.com/repos/getsentry/sentry/tags", 214 | "blobs_url": "https://api.github.com/repos/getsentry/sentry/git/blobs{/sha}", 215 | "git_tags_url": "https://api.github.com/repos/getsentry/sentry/git/tags{/sha}", 216 | "git_refs_url": "https://api.github.com/repos/getsentry/sentry/git/refs{/sha}", 217 | "trees_url": "https://api.github.com/repos/getsentry/sentry/git/trees{/sha}", 218 | "statuses_url": "https://api.github.com/repos/getsentry/sentry/statuses/{sha}", 219 | "languages_url": "https://api.github.com/repos/getsentry/sentry/languages", 220 | "stargazers_url": "https://api.github.com/repos/getsentry/sentry/stargazers", 221 | "contributors_url": "https://api.github.com/repos/getsentry/sentry/contributors", 222 | "subscribers_url": "https://api.github.com/repos/getsentry/sentry/subscribers", 223 | "subscription_url": "https://api.github.com/repos/getsentry/sentry/subscription", 224 | "commits_url": "https://api.github.com/repos/getsentry/sentry/commits{/sha}", 225 | "git_commits_url": "https://api.github.com/repos/getsentry/sentry/git/commits{/sha}", 226 | "comments_url": "https://api.github.com/repos/getsentry/sentry/comments{/number}", 227 | "issue_comment_url": "https://api.github.com/repos/getsentry/sentry/issues/comments{/number}", 228 | "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/{+path}", 229 | "compare_url": "https://api.github.com/repos/getsentry/sentry/compare/{base}...{head}", 230 | "merges_url": "https://api.github.com/repos/getsentry/sentry/merges", 231 | "archive_url": "https://api.github.com/repos/getsentry/sentry/{archive_format}{/ref}", 232 | "downloads_url": "https://api.github.com/repos/getsentry/sentry/downloads", 233 | "issues_url": "https://api.github.com/repos/getsentry/sentry/issues{/number}", 234 | "pulls_url": "https://api.github.com/repos/getsentry/sentry/pulls{/number}", 235 | "milestones_url": "https://api.github.com/repos/getsentry/sentry/milestones{/number}", 236 | "notifications_url": "https://api.github.com/repos/getsentry/sentry/notifications{?since,all,participating}", 237 | "labels_url": "https://api.github.com/repos/getsentry/sentry/labels{/name}", 238 | "releases_url": "https://api.github.com/repos/getsentry/sentry/releases{/id}", 239 | "deployments_url": "https://api.github.com/repos/getsentry/sentry/deployments" 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /tests/fixtures/jobA/trace.json: -------------------------------------------------------------------------------- 1 | { 2 | "contexts": { 3 | "trace": { 4 | "data": { 5 | "job": "https://github.com/getsentry/sentry/runs/5857527450?check_suite_focus=true", 6 | "pr": "https://github.com/getsentry/sentry/pull/33347" 7 | }, 8 | "description": "frontend tests (0)", 9 | "op": "frontend tests (0)", 10 | "span_id": "a401d83c7ec0495f", 11 | "status": "ok", 12 | "trace_id": "4d4d3477c624836b1b3a3729a7de688a", 13 | "type": "trace" 14 | } 15 | }, 16 | "event_id": "5ae279acd9824cbfa85042013cfbf8b7", 17 | "spans": [ 18 | { 19 | "name": "Set up job", 20 | "op": "Set up job", 21 | "parent_span_id": "a401d83c7ec0495f", 22 | "span_id": "4d4d3477c624836b", 23 | "start_timestamp": "2022-04-06T19:52:16.000Z", 24 | "timestamp": "2022-04-06T19:52:20.000Z", 25 | "trace_id": "4d4d3477c624836b1b3a3729a7de688a" 26 | }, 27 | { 28 | "name": "Pull ghcr.io/getsentry/action-html-to-image:latest", 29 | "op": "Pull ghcr.io/getsentry/action-html-to-image:latest", 30 | "parent_span_id": "a401d83c7ec0495f", 31 | "span_id": "0726fb4a2341477c", 32 | "start_timestamp": "2022-04-06T19:52:20.000Z", 33 | "timestamp": "2022-04-06T19:52:58.000Z", 34 | "trace_id": "4d4d3477c624836b1b3a3729a7de688a" 35 | }, 36 | { 37 | "name": "Post Checkout sentry", 38 | "op": "Post Checkout sentry", 39 | "parent_span_id": "a401d83c7ec0495f", 40 | "span_id": "a7776ed12daa449b", 41 | "start_timestamp": "2022-04-06T20:05:37.000Z", 42 | "timestamp": "2022-04-06T20:05:35.000Z", 43 | "trace_id": "4d4d3477c624836b1b3a3729a7de688a" 44 | }, 45 | { 46 | "name": "Complete job", 47 | "op": "Complete job", 48 | "parent_span_id": "a401d83c7ec0495f", 49 | "span_id": "6e147ff372f9498a", 50 | "start_timestamp": "2022-04-06T20:05:35.000Z", 51 | "timestamp": "2022-04-06T20:05:35.000Z", 52 | "trace_id": "4d4d3477c624836b1b3a3729a7de688a" 53 | } 54 | ], 55 | "start_timestamp": "2022-04-06T19:52:17Z", 56 | "tags": { 57 | "branch": "ahmed/add-naming-layer", 58 | "commit": "fd976218a94d8a0cd203c711ea7dbe68c573ef4d", 59 | "job_status": "success", 60 | "pull_request": 33347, 61 | "repo": "getsentry/sentry", 62 | "run_attempt": 1, 63 | "workflow": "acceptance.yml" 64 | }, 65 | "timestamp": "2022-04-06T20:05:37Z", 66 | "transaction": "frontend tests (0)", 67 | "type": "transaction", 68 | "user": { 69 | "email": "ahmed.etefy12@gmail.com", 70 | "name": "Ahmed Etefy" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/fixtures/jobA/workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1174556, 3 | "node_id": "MDg6V29ya2Zsb3cxMTc0NTU2", 4 | "name": "acceptance", 5 | "path": ".github/workflows/acceptance.yml", 6 | "state": "active", 7 | "created_at": "2020-05-01T23:00:30.000Z", 8 | "updated_at": "2022-03-17T18:24:06.000Z", 9 | "url": "https://api.github.com/repos/getsentry/sentry/actions/workflows/1174556", 10 | "html_url": "https://github.com/getsentry/sentry/blob/master/.github/workflows/acceptance.yml", 11 | "badge_url": "https://github.com/getsentry/sentry/workflows/acceptance/badge.svg" 12 | } 13 | -------------------------------------------------------------------------------- /tests/fixtures/skipped_workflow.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 2098089400, 3 | "name": "sentry pull request bot", 4 | "node_id": "WFR_kwLOAA1TcM59Dk24", 5 | "head_branch": "master", 6 | "head_sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 7 | "run_number": 31031, 8 | "event": "issue_comment", 9 | "status": "completed", 10 | "conclusion": "skipped", 11 | "workflow_id": 3020796, 12 | "check_suite_id": 5943587195, 13 | "check_suite_node_id": "CS_kwDOAA1TcM8AAAABYkPxew", 14 | "url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2098089400", 15 | "html_url": "https://github.com/getsentry/sentry/actions/runs/2098089400", 16 | "pull_requests": [ 17 | { 18 | "url": "https://api.github.com/repos/heholek/sentry/pulls/86", 19 | "id": 894496956, 20 | "number": 86, 21 | "head": { 22 | "ref": "master", 23 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 24 | "repo": { 25 | "id": 873328, 26 | "url": "https://api.github.com/repos/getsentry/sentry", 27 | "name": "sentry" 28 | } 29 | }, 30 | "base": { 31 | "ref": "master", 32 | "sha": "f05d2d00449b044f1f81ad294c6d2988cf504a1e", 33 | "repo": { 34 | "id": 447747682, 35 | "url": "https://api.github.com/repos/heholek/sentry", 36 | "name": "sentry" 37 | } 38 | } 39 | }, 40 | { 41 | "url": "https://api.github.com/repos/Nexuscompute/sentry/pulls/226", 42 | "id": 893903127, 43 | "number": 226, 44 | "head": { 45 | "ref": "master", 46 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 47 | "repo": { 48 | "id": 873328, 49 | "url": "https://api.github.com/repos/getsentry/sentry", 50 | "name": "sentry" 51 | } 52 | }, 53 | "base": { 54 | "ref": "master", 55 | "sha": "f05d2d00449b044f1f81ad294c6d2988cf504a1e", 56 | "repo": { 57 | "id": 435303402, 58 | "url": "https://api.github.com/repos/Nexuscompute/sentry", 59 | "name": "sentry" 60 | } 61 | } 62 | }, 63 | { 64 | "url": "https://api.github.com/repos/sljeff/sentry/pulls/204", 65 | "id": 887021323, 66 | "number": 204, 67 | "head": { 68 | "ref": "master", 69 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 70 | "repo": { 71 | "id": 873328, 72 | "url": "https://api.github.com/repos/getsentry/sentry", 73 | "name": "sentry" 74 | } 75 | }, 76 | "base": { 77 | "ref": "master", 78 | "sha": "f05d2d00449b044f1f81ad294c6d2988cf504a1e", 79 | "repo": { 80 | "id": 428910254, 81 | "url": "https://api.github.com/repos/sljeff/sentry", 82 | "name": "sentry" 83 | } 84 | } 85 | }, 86 | { 87 | "url": "https://api.github.com/repos/NeatNerdPrime/sentry/pulls/1423", 88 | "id": 884824402, 89 | "number": 1423, 90 | "head": { 91 | "ref": "master", 92 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 93 | "repo": { 94 | "id": 873328, 95 | "url": "https://api.github.com/repos/getsentry/sentry", 96 | "name": "sentry" 97 | } 98 | }, 99 | "base": { 100 | "ref": "master", 101 | "sha": "8429cf33623b759a3ff7bddcf13d251b0dab9b8e", 102 | "repo": { 103 | "id": 212712897, 104 | "url": "https://api.github.com/repos/NeatNerdPrime/sentry", 105 | "name": "sentry" 106 | } 107 | } 108 | }, 109 | { 110 | "url": "https://api.github.com/repos/littlekign/sentry/pulls/100", 111 | "id": 841827111, 112 | "number": 100, 113 | "head": { 114 | "ref": "master", 115 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 116 | "repo": { 117 | "id": 873328, 118 | "url": "https://api.github.com/repos/getsentry/sentry", 119 | "name": "sentry" 120 | } 121 | }, 122 | "base": { 123 | "ref": "master", 124 | "sha": "b2aa926c73ae5bf70ac538a9794e6a5ad37fd8cd", 125 | "repo": { 126 | "id": 253251945, 127 | "url": "https://api.github.com/repos/littlekign/sentry", 128 | "name": "sentry" 129 | } 130 | } 131 | }, 132 | { 133 | "url": "https://api.github.com/repos/httpsgithu/sentry/pulls/22", 134 | "id": 750344111, 135 | "number": 22, 136 | "head": { 137 | "ref": "master", 138 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 139 | "repo": { 140 | "id": 873328, 141 | "url": "https://api.github.com/repos/getsentry/sentry", 142 | "name": "sentry" 143 | } 144 | }, 145 | "base": { 146 | "ref": "master", 147 | "sha": "284fefe45ccc3d21de6e52ce8a480c10b5bef3c1", 148 | "repo": { 149 | "id": 250191602, 150 | "url": "https://api.github.com/repos/httpsgithu/sentry", 151 | "name": "sentry" 152 | } 153 | } 154 | }, 155 | { 156 | "url": "https://api.github.com/repos/KingDEV95/sentry/pulls/313", 157 | "id": 750313238, 158 | "number": 313, 159 | "head": { 160 | "ref": "master", 161 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 162 | "repo": { 163 | "id": 873328, 164 | "url": "https://api.github.com/repos/getsentry/sentry", 165 | "name": "sentry" 166 | } 167 | }, 168 | "base": { 169 | "ref": "master", 170 | "sha": "576dbb92b9f5bec8bec4a1759610583e9237a49b", 171 | "repo": { 172 | "id": 256645481, 173 | "url": "https://api.github.com/repos/KingDEV95/sentry", 174 | "name": "sentry" 175 | } 176 | } 177 | }, 178 | { 179 | "url": "https://api.github.com/repos/skyplaying/sentry/pulls/57", 180 | "id": 750273455, 181 | "number": 57, 182 | "head": { 183 | "ref": "master", 184 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 185 | "repo": { 186 | "id": 873328, 187 | "url": "https://api.github.com/repos/getsentry/sentry", 188 | "name": "sentry" 189 | } 190 | }, 191 | "base": { 192 | "ref": "master", 193 | "sha": "576dbb92b9f5bec8bec4a1759610583e9237a49b", 194 | "repo": { 195 | "id": 340541581, 196 | "url": "https://api.github.com/repos/skyplaying/sentry", 197 | "name": "sentry" 198 | } 199 | } 200 | }, 201 | { 202 | "url": "https://api.github.com/repos/dk-dev/sentry/pulls/335", 203 | "id": 622223860, 204 | "number": 335, 205 | "head": { 206 | "ref": "master", 207 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 208 | "repo": { 209 | "id": 873328, 210 | "url": "https://api.github.com/repos/getsentry/sentry", 211 | "name": "sentry" 212 | } 213 | }, 214 | "base": { 215 | "ref": "master", 216 | "sha": "4645a05b5ad92c6eaf522f88063219d0d784be07", 217 | "repo": { 218 | "id": 13605132, 219 | "url": "https://api.github.com/repos/dk-dev/sentry", 220 | "name": "sentry" 221 | } 222 | } 223 | }, 224 | { 225 | "url": "https://api.github.com/repos/waterdrops/sentry/pulls/17", 226 | "id": 620768325, 227 | "number": 17, 228 | "head": { 229 | "ref": "master", 230 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 231 | "repo": { 232 | "id": 873328, 233 | "url": "https://api.github.com/repos/getsentry/sentry", 234 | "name": "sentry" 235 | } 236 | }, 237 | "base": { 238 | "ref": "master", 239 | "sha": "12678a7dfc9f604485ea1847ab631a3d92670338", 240 | "repo": { 241 | "id": 82283669, 242 | "url": "https://api.github.com/repos/waterdrops/sentry", 243 | "name": "sentry" 244 | } 245 | } 246 | }, 247 | { 248 | "url": "https://api.github.com/repos/symfu/sentry/pulls/1165", 249 | "id": 582026148, 250 | "number": 1165, 251 | "head": { 252 | "ref": "master", 253 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 254 | "repo": { 255 | "id": 873328, 256 | "url": "https://api.github.com/repos/getsentry/sentry", 257 | "name": "sentry" 258 | } 259 | }, 260 | "base": { 261 | "ref": "master", 262 | "sha": "0902abd3d3528df231fffd3929730495dcf65f79", 263 | "repo": { 264 | "id": 224076470, 265 | "url": "https://api.github.com/repos/symfu/sentry", 266 | "name": "sentry" 267 | } 268 | } 269 | }, 270 | { 271 | "url": "https://api.github.com/repos/fakegit/sentry/pulls/1132", 272 | "id": 574585722, 273 | "number": 1132, 274 | "head": { 275 | "ref": "master", 276 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 277 | "repo": { 278 | "id": 873328, 279 | "url": "https://api.github.com/repos/getsentry/sentry", 280 | "name": "sentry" 281 | } 282 | }, 283 | "base": { 284 | "ref": "master", 285 | "sha": "d830eb47b4c60ae45994464e157587b5880cce53", 286 | "repo": { 287 | "id": 220568438, 288 | "url": "https://api.github.com/repos/fakegit/sentry", 289 | "name": "sentry" 290 | } 291 | } 292 | }, 293 | { 294 | "url": "https://api.github.com/repos/dgkh1975/sentry/pulls/1", 295 | "id": 568785494, 296 | "number": 1, 297 | "head": { 298 | "ref": "master", 299 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 300 | "repo": { 301 | "id": 873328, 302 | "url": "https://api.github.com/repos/getsentry/sentry", 303 | "name": "sentry" 304 | } 305 | }, 306 | "base": { 307 | "ref": "master", 308 | "sha": "4f1d6dbdfd9ea5cdb785e009af2ef91d8f988dc8", 309 | "repo": { 310 | "id": 333858862, 311 | "url": "https://api.github.com/repos/dgkh1975/sentry", 312 | "name": "sentry" 313 | } 314 | } 315 | }, 316 | { 317 | "url": "https://api.github.com/repos/Mu-L/sentry/pulls/48", 318 | "id": 563125460, 319 | "number": 48, 320 | "head": { 321 | "ref": "master", 322 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 323 | "repo": { 324 | "id": 873328, 325 | "url": "https://api.github.com/repos/getsentry/sentry", 326 | "name": "sentry" 327 | } 328 | }, 329 | "base": { 330 | "ref": "master", 331 | "sha": "ebcf4b671702fe9bf01c4921a766593c74e26608", 332 | "repo": { 333 | "id": 295943252, 334 | "url": "https://api.github.com/repos/Mu-L/sentry", 335 | "name": "sentry" 336 | } 337 | } 338 | }, 339 | { 340 | "url": "https://api.github.com/repos/Jankyboy/sentry/pulls/1", 341 | "id": 495898142, 342 | "number": 1, 343 | "head": { 344 | "ref": "master", 345 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 346 | "repo": { 347 | "id": 873328, 348 | "url": "https://api.github.com/repos/getsentry/sentry", 349 | "name": "sentry" 350 | } 351 | }, 352 | "base": { 353 | "ref": "master", 354 | "sha": "31f606045ce6c1c96df8869f6ae0e890bec8cbcc", 355 | "repo": { 356 | "id": 300087432, 357 | "url": "https://api.github.com/repos/Jankyboy/sentry", 358 | "name": "sentry" 359 | } 360 | } 361 | }, 362 | { 363 | "url": "https://api.github.com/repos/Mattlk13/sentry-1/pulls/243", 364 | "id": 302696163, 365 | "number": 243, 366 | "head": { 367 | "ref": "master", 368 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 369 | "repo": { 370 | "id": 873328, 371 | "url": "https://api.github.com/repos/getsentry/sentry", 372 | "name": "sentry" 373 | } 374 | }, 375 | "base": { 376 | "ref": "master", 377 | "sha": "19b0870916b80250f3cb69277641bfdd03320415", 378 | "repo": { 379 | "id": 81418058, 380 | "url": "https://api.github.com/repos/Mattlk13/sentry-1", 381 | "name": "sentry-1" 382 | } 383 | } 384 | }, 385 | { 386 | "url": "https://api.github.com/repos/simmetria/sentry/pulls/1", 387 | "id": 26629667, 388 | "number": 1, 389 | "head": { 390 | "ref": "master", 391 | "sha": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 392 | "repo": { 393 | "id": 873328, 394 | "url": "https://api.github.com/repos/getsentry/sentry", 395 | "name": "sentry" 396 | } 397 | }, 398 | "base": { 399 | "ref": "master", 400 | "sha": "9731f26adb44847d1c883cca108afc0755cf21cc", 401 | "repo": { 402 | "id": 6701590, 403 | "url": "https://api.github.com/repos/simmetria/sentry", 404 | "name": "sentry" 405 | } 406 | } 407 | } 408 | ], 409 | "created_at": "2022-04-05T18:33:44Z", 410 | "updated_at": "2022-04-05T18:33:47Z", 411 | "actor": { 412 | "login": "JoshFerge", 413 | "id": 1976777, 414 | "node_id": "MDQ6VXNlcjE5NzY3Nzc=", 415 | "avatar_url": "https://avatars.githubusercontent.com/u/1976777?v=4", 416 | "gravatar_id": "", 417 | "url": "https://api.github.com/users/JoshFerge", 418 | "html_url": "https://github.com/JoshFerge", 419 | "followers_url": "https://api.github.com/users/JoshFerge/followers", 420 | "following_url": "https://api.github.com/users/JoshFerge/following{/other_user}", 421 | "gists_url": "https://api.github.com/users/JoshFerge/gists{/gist_id}", 422 | "starred_url": "https://api.github.com/users/JoshFerge/starred{/owner}{/repo}", 423 | "subscriptions_url": "https://api.github.com/users/JoshFerge/subscriptions", 424 | "organizations_url": "https://api.github.com/users/JoshFerge/orgs", 425 | "repos_url": "https://api.github.com/users/JoshFerge/repos", 426 | "events_url": "https://api.github.com/users/JoshFerge/events{/privacy}", 427 | "received_events_url": "https://api.github.com/users/JoshFerge/received_events", 428 | "type": "User", 429 | "site_admin": false 430 | }, 431 | "run_attempt": 1, 432 | "run_started_at": "2022-04-05T18:33:44Z", 433 | "triggering_actor": { 434 | "login": "JoshFerge", 435 | "id": 1976777, 436 | "node_id": "MDQ6VXNlcjE5NzY3Nzc=", 437 | "avatar_url": "https://avatars.githubusercontent.com/u/1976777?v=4", 438 | "gravatar_id": "", 439 | "url": "https://api.github.com/users/JoshFerge", 440 | "html_url": "https://github.com/JoshFerge", 441 | "followers_url": "https://api.github.com/users/JoshFerge/followers", 442 | "following_url": "https://api.github.com/users/JoshFerge/following{/other_user}", 443 | "gists_url": "https://api.github.com/users/JoshFerge/gists{/gist_id}", 444 | "starred_url": "https://api.github.com/users/JoshFerge/starred{/owner}{/repo}", 445 | "subscriptions_url": "https://api.github.com/users/JoshFerge/subscriptions", 446 | "organizations_url": "https://api.github.com/users/JoshFerge/orgs", 447 | "repos_url": "https://api.github.com/users/JoshFerge/repos", 448 | "events_url": "https://api.github.com/users/JoshFerge/events{/privacy}", 449 | "received_events_url": "https://api.github.com/users/JoshFerge/received_events", 450 | "type": "User", 451 | "site_admin": false 452 | }, 453 | "jobs_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2098089400/jobs", 454 | "logs_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2098089400/logs", 455 | "check_suite_url": "https://api.github.com/repos/getsentry/sentry/check-suites/5943587195", 456 | "artifacts_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2098089400/artifacts", 457 | "cancel_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2098089400/cancel", 458 | "rerun_url": "https://api.github.com/repos/getsentry/sentry/actions/runs/2098089400/rerun", 459 | "previous_attempt_url": null, 460 | "workflow_url": "https://api.github.com/repos/getsentry/sentry/actions/workflows/3020796", 461 | "head_commit": { 462 | "id": "e6f148cf35ff4d7e5da932cd3b1ffe8f40b1af52", 463 | "tree_id": "d02c6f802c073da7d1f0605ddabe7980847a7798", 464 | "message": "chore(eslint): Don't specify the .eslintrc.* file in vscode workspace settings (#33308)\n\nI saw an error in the `vscode-eslint` plugin:\r\n```\r\nError: Invalid Options:\r\n- Unknown options: configFile\r\n- 'configFile' has been removed. Please use the 'overrideConfigFile' option instead.\r\n```\r\n\r\nThe plugin can find our eslintrc file without the explicit `configFile` field because the file sticks to the normal naming convention.", 465 | "timestamp": "2022-04-05T18:26:04Z", 466 | "author": { 467 | "name": "Ryan Albrecht", 468 | "email": "ryan.albrecht@sentry.io" 469 | }, 470 | "committer": { 471 | "name": "GitHub", 472 | "email": "noreply@github.com" 473 | } 474 | }, 475 | "repository": { 476 | "id": 873328, 477 | "node_id": "MDEwOlJlcG9zaXRvcnk4NzMzMjg=", 478 | "name": "sentry", 479 | "full_name": "getsentry/sentry", 480 | "private": false, 481 | "owner": { 482 | "login": "getsentry", 483 | "id": 1396951, 484 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjEzOTY5NTE=", 485 | "avatar_url": "https://avatars.githubusercontent.com/u/1396951?v=4", 486 | "gravatar_id": "", 487 | "url": "https://api.github.com/users/getsentry", 488 | "html_url": "https://github.com/getsentry", 489 | "followers_url": "https://api.github.com/users/getsentry/followers", 490 | "following_url": "https://api.github.com/users/getsentry/following{/other_user}", 491 | "gists_url": "https://api.github.com/users/getsentry/gists{/gist_id}", 492 | "starred_url": "https://api.github.com/users/getsentry/starred{/owner}{/repo}", 493 | "subscriptions_url": "https://api.github.com/users/getsentry/subscriptions", 494 | "organizations_url": "https://api.github.com/users/getsentry/orgs", 495 | "repos_url": "https://api.github.com/users/getsentry/repos", 496 | "events_url": "https://api.github.com/users/getsentry/events{/privacy}", 497 | "received_events_url": "https://api.github.com/users/getsentry/received_events", 498 | "type": "Organization", 499 | "site_admin": false 500 | }, 501 | "html_url": "https://github.com/getsentry/sentry", 502 | "description": "Sentry is cross-platform application monitoring, with a focus on error reporting.", 503 | "fork": false, 504 | "url": "https://api.github.com/repos/getsentry/sentry", 505 | "forks_url": "https://api.github.com/repos/getsentry/sentry/forks", 506 | "keys_url": "https://api.github.com/repos/getsentry/sentry/keys{/key_id}", 507 | "collaborators_url": "https://api.github.com/repos/getsentry/sentry/collaborators{/collaborator}", 508 | "teams_url": "https://api.github.com/repos/getsentry/sentry/teams", 509 | "hooks_url": "https://api.github.com/repos/getsentry/sentry/hooks", 510 | "issue_events_url": "https://api.github.com/repos/getsentry/sentry/issues/events{/number}", 511 | "events_url": "https://api.github.com/repos/getsentry/sentry/events", 512 | "assignees_url": "https://api.github.com/repos/getsentry/sentry/assignees{/user}", 513 | "branches_url": "https://api.github.com/repos/getsentry/sentry/branches{/branch}", 514 | "tags_url": "https://api.github.com/repos/getsentry/sentry/tags", 515 | "blobs_url": "https://api.github.com/repos/getsentry/sentry/git/blobs{/sha}", 516 | "git_tags_url": "https://api.github.com/repos/getsentry/sentry/git/tags{/sha}", 517 | "git_refs_url": "https://api.github.com/repos/getsentry/sentry/git/refs{/sha}", 518 | "trees_url": "https://api.github.com/repos/getsentry/sentry/git/trees{/sha}", 519 | "statuses_url": "https://api.github.com/repos/getsentry/sentry/statuses/{sha}", 520 | "languages_url": "https://api.github.com/repos/getsentry/sentry/languages", 521 | "stargazers_url": "https://api.github.com/repos/getsentry/sentry/stargazers", 522 | "contributors_url": "https://api.github.com/repos/getsentry/sentry/contributors", 523 | "subscribers_url": "https://api.github.com/repos/getsentry/sentry/subscribers", 524 | "subscription_url": "https://api.github.com/repos/getsentry/sentry/subscription", 525 | "commits_url": "https://api.github.com/repos/getsentry/sentry/commits{/sha}", 526 | "git_commits_url": "https://api.github.com/repos/getsentry/sentry/git/commits{/sha}", 527 | "comments_url": "https://api.github.com/repos/getsentry/sentry/comments{/number}", 528 | "issue_comment_url": "https://api.github.com/repos/getsentry/sentry/issues/comments{/number}", 529 | "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/{+path}", 530 | "compare_url": "https://api.github.com/repos/getsentry/sentry/compare/{base}...{head}", 531 | "merges_url": "https://api.github.com/repos/getsentry/sentry/merges", 532 | "archive_url": "https://api.github.com/repos/getsentry/sentry/{archive_format}{/ref}", 533 | "downloads_url": "https://api.github.com/repos/getsentry/sentry/downloads", 534 | "issues_url": "https://api.github.com/repos/getsentry/sentry/issues{/number}", 535 | "pulls_url": "https://api.github.com/repos/getsentry/sentry/pulls{/number}", 536 | "milestones_url": "https://api.github.com/repos/getsentry/sentry/milestones{/number}", 537 | "notifications_url": "https://api.github.com/repos/getsentry/sentry/notifications{?since,all,participating}", 538 | "labels_url": "https://api.github.com/repos/getsentry/sentry/labels{/name}", 539 | "releases_url": "https://api.github.com/repos/getsentry/sentry/releases{/id}", 540 | "deployments_url": "https://api.github.com/repos/getsentry/sentry/deployments" 541 | }, 542 | "head_repository": { 543 | "id": 873328, 544 | "node_id": "MDEwOlJlcG9zaXRvcnk4NzMzMjg=", 545 | "name": "sentry", 546 | "full_name": "getsentry/sentry", 547 | "private": false, 548 | "owner": { 549 | "login": "getsentry", 550 | "id": 1396951, 551 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjEzOTY5NTE=", 552 | "avatar_url": "https://avatars.githubusercontent.com/u/1396951?v=4", 553 | "gravatar_id": "", 554 | "url": "https://api.github.com/users/getsentry", 555 | "html_url": "https://github.com/getsentry", 556 | "followers_url": "https://api.github.com/users/getsentry/followers", 557 | "following_url": "https://api.github.com/users/getsentry/following{/other_user}", 558 | "gists_url": "https://api.github.com/users/getsentry/gists{/gist_id}", 559 | "starred_url": "https://api.github.com/users/getsentry/starred{/owner}{/repo}", 560 | "subscriptions_url": "https://api.github.com/users/getsentry/subscriptions", 561 | "organizations_url": "https://api.github.com/users/getsentry/orgs", 562 | "repos_url": "https://api.github.com/users/getsentry/repos", 563 | "events_url": "https://api.github.com/users/getsentry/events{/privacy}", 564 | "received_events_url": "https://api.github.com/users/getsentry/received_events", 565 | "type": "Organization", 566 | "site_admin": false 567 | }, 568 | "html_url": "https://github.com/getsentry/sentry", 569 | "description": "Sentry is cross-platform application monitoring, with a focus on error reporting.", 570 | "fork": false, 571 | "url": "https://api.github.com/repos/getsentry/sentry", 572 | "forks_url": "https://api.github.com/repos/getsentry/sentry/forks", 573 | "keys_url": "https://api.github.com/repos/getsentry/sentry/keys{/key_id}", 574 | "collaborators_url": "https://api.github.com/repos/getsentry/sentry/collaborators{/collaborator}", 575 | "teams_url": "https://api.github.com/repos/getsentry/sentry/teams", 576 | "hooks_url": "https://api.github.com/repos/getsentry/sentry/hooks", 577 | "issue_events_url": "https://api.github.com/repos/getsentry/sentry/issues/events{/number}", 578 | "events_url": "https://api.github.com/repos/getsentry/sentry/events", 579 | "assignees_url": "https://api.github.com/repos/getsentry/sentry/assignees{/user}", 580 | "branches_url": "https://api.github.com/repos/getsentry/sentry/branches{/branch}", 581 | "tags_url": "https://api.github.com/repos/getsentry/sentry/tags", 582 | "blobs_url": "https://api.github.com/repos/getsentry/sentry/git/blobs{/sha}", 583 | "git_tags_url": "https://api.github.com/repos/getsentry/sentry/git/tags{/sha}", 584 | "git_refs_url": "https://api.github.com/repos/getsentry/sentry/git/refs{/sha}", 585 | "trees_url": "https://api.github.com/repos/getsentry/sentry/git/trees{/sha}", 586 | "statuses_url": "https://api.github.com/repos/getsentry/sentry/statuses/{sha}", 587 | "languages_url": "https://api.github.com/repos/getsentry/sentry/languages", 588 | "stargazers_url": "https://api.github.com/repos/getsentry/sentry/stargazers", 589 | "contributors_url": "https://api.github.com/repos/getsentry/sentry/contributors", 590 | "subscribers_url": "https://api.github.com/repos/getsentry/sentry/subscribers", 591 | "subscription_url": "https://api.github.com/repos/getsentry/sentry/subscription", 592 | "commits_url": "https://api.github.com/repos/getsentry/sentry/commits{/sha}", 593 | "git_commits_url": "https://api.github.com/repos/getsentry/sentry/git/commits{/sha}", 594 | "comments_url": "https://api.github.com/repos/getsentry/sentry/comments{/number}", 595 | "issue_comment_url": "https://api.github.com/repos/getsentry/sentry/issues/comments{/number}", 596 | "contents_url": "https://api.github.com/repos/getsentry/sentry/contents/{+path}", 597 | "compare_url": "https://api.github.com/repos/getsentry/sentry/compare/{base}...{head}", 598 | "merges_url": "https://api.github.com/repos/getsentry/sentry/merges", 599 | "archive_url": "https://api.github.com/repos/getsentry/sentry/{archive_format}{/ref}", 600 | "downloads_url": "https://api.github.com/repos/getsentry/sentry/downloads", 601 | "issues_url": "https://api.github.com/repos/getsentry/sentry/issues{/number}", 602 | "pulls_url": "https://api.github.com/repos/getsentry/sentry/pulls{/number}", 603 | "milestones_url": "https://api.github.com/repos/getsentry/sentry/milestones{/number}", 604 | "notifications_url": "https://api.github.com/repos/getsentry/sentry/notifications{?since,all,participating}", 605 | "labels_url": "https://api.github.com/repos/getsentry/sentry/labels{/name}", 606 | "releases_url": "https://api.github.com/repos/getsentry/sentry/releases{/id}", 607 | "deployments_url": "https://api.github.com/repos/getsentry/sentry/deployments" 608 | } 609 | } 610 | -------------------------------------------------------------------------------- /tests/fixtures/webhook_event.json: -------------------------------------------------------------------------------- 1 | { 2 | "headers": { 3 | "Request URL": "https://github-actions-app-dwunkkvj6a-uc.a.run.app/", 4 | "Request method": "POST", 5 | "Accept": "*/*", 6 | "content-type": "application/json", 7 | "User-Agent": "GitHub-Hookshot/d0ceae1", 8 | "X-GitHub-Delivery": "99ba54a0-d21e-11ec-8158-e3ba791db828", 9 | "X-GitHub-Event": "workflow_job", 10 | "X-GitHub-Hook-ID": 349900435, 11 | "X-GitHub-Hook-Installation-Target-ID": 873328, 12 | "X-GitHub-Hook-Installation-Target-Type": "repository", 13 | "X-Hub-Signature": "sha1=aaaeb75e9ef80af1a95ffdf4b4b8b2a69ff8ff69", 14 | "X-Hub-Signature-256": "sha256=d9259f51d3b64e7fe0cbe09d9b08b8ee763170d3521fecc35fd8b453be8cf6a5" 15 | }, 16 | "payload": { 17 | "action": "completed", 18 | "workflow_job": { 19 | "id": 3283437728, 20 | "run_id": 1113865931, 21 | "run_attempt": 2, 22 | "run_url": "https://api.github.com/repos/armenzg/sentry-hackweek/actions/runs/1113865931", 23 | "node_id": "MDg6Q2hlY2tSdW4zMjgzNDM3NzI4", 24 | "head_sha": "2238b40c4b398874db76239c1d60325af9037563", 25 | "url": "https://api.github.com/repos/armenzg/sentry-hackweek/actions/jobs/3283437728", 26 | "html_url": "https://github.com/armenzg/sentry-hackweek/runs/3283437728", 27 | "status": "completed", 28 | "conclusion": "success", 29 | "started_at": "2021-08-09T18:12:37Z", 30 | "completed_at": "2021-08-09T18:12:41Z", 31 | "name": "salutation", 32 | "steps": [ 33 | { 34 | "name": "Set up job", 35 | "status": "completed", 36 | "conclusion": "success", 37 | "number": 1, 38 | "started_at": "2021-08-09T18:12:37.000Z", 39 | "completed_at": "2021-08-09T18:12:40.000Z" 40 | }, 41 | { 42 | "name": "Checkout", 43 | "status": "completed", 44 | "conclusion": "success", 45 | "number": 2, 46 | "started_at": "2021-08-09T18:12:40.000Z", 47 | "completed_at": "2021-08-09T18:12:40.000Z" 48 | }, 49 | { 50 | "name": "Welcome", 51 | "status": "completed", 52 | "conclusion": "success", 53 | "number": 3, 54 | "started_at": "2021-08-09T18:12:40.000Z", 55 | "completed_at": "2021-08-09T18:12:40.000Z" 56 | }, 57 | { 58 | "name": "Post Checkout", 59 | "status": "completed", 60 | "conclusion": "success", 61 | "number": 6, 62 | "started_at": "2021-08-09T18:12:40.000Z", 63 | "completed_at": "2021-08-09T18:12:41.000Z" 64 | }, 65 | { 66 | "name": "Complete job", 67 | "status": "completed", 68 | "conclusion": "success", 69 | "number": 7, 70 | "started_at": "2021-08-09T18:12:41.000Z", 71 | "completed_at": "2021-08-09T18:12:41.000Z" 72 | } 73 | ], 74 | "check_run_url": "https://api.github.com/repos/armenzg/sentry-hackweek/check-runs/3283437728", 75 | "labels": ["ubuntu-latest"] 76 | }, 77 | "repository": { 78 | "id": 394302061, 79 | "node_id": "MDEwOlJlcG9zaXRvcnkzOTQzMDIwNjE=", 80 | "name": "sentry-hackweek", 81 | "full_name": "armenzg/sentry-hackweek", 82 | "private": false, 83 | "owner": { 84 | "login": "armenzg", 85 | "id": 44410, 86 | "node_id": "MDQ6VXNlcjQ0NDEw", 87 | "avatar_url": "https://avatars.githubusercontent.com/u/44410?v=4", 88 | "gravatar_id": "", 89 | "url": "https://api.github.com/users/armenzg", 90 | "html_url": "https://github.com/armenzg", 91 | "followers_url": "https://api.github.com/users/armenzg/followers", 92 | "following_url": "https://api.github.com/users/armenzg/following{/other_user}", 93 | "gists_url": "https://api.github.com/users/armenzg/gists{/gist_id}", 94 | "starred_url": "https://api.github.com/users/armenzg/starred{/owner}{/repo}", 95 | "subscriptions_url": "https://api.github.com/users/armenzg/subscriptions", 96 | "organizations_url": "https://api.github.com/users/armenzg/orgs", 97 | "repos_url": "https://api.github.com/users/armenzg/repos", 98 | "events_url": "https://api.github.com/users/armenzg/events{/privacy}", 99 | "received_events_url": "https://api.github.com/users/armenzg/received_events", 100 | "type": "User", 101 | "site_admin": false 102 | }, 103 | "html_url": "https://github.com/armenzg/sentry-hackweek", 104 | "description": null, 105 | "fork": false, 106 | "url": "https://api.github.com/repos/armenzg/sentry-hackweek", 107 | "forks_url": "https://api.github.com/repos/armenzg/sentry-hackweek/forks", 108 | "keys_url": "https://api.github.com/repos/armenzg/sentry-hackweek/keys{/key_id}", 109 | "collaborators_url": "https://api.github.com/repos/armenzg/sentry-hackweek/collaborators{/collaborator}", 110 | "teams_url": "https://api.github.com/repos/armenzg/sentry-hackweek/teams", 111 | "hooks_url": "https://api.github.com/repos/armenzg/sentry-hackweek/hooks", 112 | "issue_events_url": "https://api.github.com/repos/armenzg/sentry-hackweek/issues/events{/number}", 113 | "events_url": "https://api.github.com/repos/armenzg/sentry-hackweek/events", 114 | "assignees_url": "https://api.github.com/repos/armenzg/sentry-hackweek/assignees{/user}", 115 | "branches_url": "https://api.github.com/repos/armenzg/sentry-hackweek/branches{/branch}", 116 | "tags_url": "https://api.github.com/repos/armenzg/sentry-hackweek/tags", 117 | "blobs_url": "https://api.github.com/repos/armenzg/sentry-hackweek/git/blobs{/sha}", 118 | "git_tags_url": "https://api.github.com/repos/armenzg/sentry-hackweek/git/tags{/sha}", 119 | "git_refs_url": "https://api.github.com/repos/armenzg/sentry-hackweek/git/refs{/sha}", 120 | "trees_url": "https://api.github.com/repos/armenzg/sentry-hackweek/git/trees{/sha}", 121 | "statuses_url": "https://api.github.com/repos/armenzg/sentry-hackweek/statuses/{sha}", 122 | "languages_url": "https://api.github.com/repos/armenzg/sentry-hackweek/languages", 123 | "stargazers_url": "https://api.github.com/repos/armenzg/sentry-hackweek/stargazers", 124 | "contributors_url": "https://api.github.com/repos/armenzg/sentry-hackweek/contributors", 125 | "subscribers_url": "https://api.github.com/repos/armenzg/sentry-hackweek/subscribers", 126 | "subscription_url": "https://api.github.com/repos/armenzg/sentry-hackweek/subscription", 127 | "commits_url": "https://api.github.com/repos/armenzg/sentry-hackweek/commits{/sha}", 128 | "git_commits_url": "https://api.github.com/repos/armenzg/sentry-hackweek/git/commits{/sha}", 129 | "comments_url": "https://api.github.com/repos/armenzg/sentry-hackweek/comments{/number}", 130 | "issue_comment_url": "https://api.github.com/repos/armenzg/sentry-hackweek/issues/comments{/number}", 131 | "contents_url": "https://api.github.com/repos/armenzg/sentry-hackweek/contents/{+path}", 132 | "compare_url": "https://api.github.com/repos/armenzg/sentry-hackweek/compare/{base}...{head}", 133 | "merges_url": "https://api.github.com/repos/armenzg/sentry-hackweek/merges", 134 | "archive_url": "https://api.github.com/repos/armenzg/sentry-hackweek/{archive_format}{/ref}", 135 | "downloads_url": "https://api.github.com/repos/armenzg/sentry-hackweek/downloads", 136 | "issues_url": "https://api.github.com/repos/armenzg/sentry-hackweek/issues{/number}", 137 | "pulls_url": "https://api.github.com/repos/armenzg/sentry-hackweek/pulls{/number}", 138 | "milestones_url": "https://api.github.com/repos/armenzg/sentry-hackweek/milestones{/number}", 139 | "notifications_url": "https://api.github.com/repos/armenzg/sentry-hackweek/notifications{?since,all,participating}", 140 | "labels_url": "https://api.github.com/repos/armenzg/sentry-hackweek/labels{/name}", 141 | "releases_url": "https://api.github.com/repos/armenzg/sentry-hackweek/releases{/id}", 142 | "deployments_url": "https://api.github.com/repos/armenzg/sentry-hackweek/deployments", 143 | "created_at": "2021-08-09T13:28:32Z", 144 | "updated_at": "2021-08-09T13:28:35Z", 145 | "pushed_at": "2021-08-09T18:12:28Z", 146 | "git_url": "git://github.com/armenzg/sentry-hackweek.git", 147 | "ssh_url": "git@github.com:armenzg/sentry-hackweek.git", 148 | "clone_url": "https://github.com/armenzg/sentry-hackweek.git", 149 | "svn_url": "https://github.com/armenzg/sentry-hackweek", 150 | "homepage": null, 151 | "size": 2, 152 | "stargazers_count": 0, 153 | "watchers_count": 0, 154 | "language": null, 155 | "has_issues": true, 156 | "has_projects": true, 157 | "has_downloads": true, 158 | "has_wiki": true, 159 | "has_pages": false, 160 | "forks_count": 0, 161 | "mirror_url": null, 162 | "archived": false, 163 | "disabled": false, 164 | "open_issues_count": 2, 165 | "license": null, 166 | "forks": 0, 167 | "open_issues": 2, 168 | "watchers": 0, 169 | "default_branch": "main" 170 | }, 171 | "sender": { 172 | "login": "armenzg", 173 | "id": 44410, 174 | "node_id": "MDQ6VXNlcjQ0NDEw", 175 | "avatar_url": "https://avatars.githubusercontent.com/u/44410?v=4", 176 | "gravatar_id": "", 177 | "url": "https://api.github.com/users/armenzg", 178 | "html_url": "https://github.com/armenzg", 179 | "followers_url": "https://api.github.com/users/armenzg/followers", 180 | "following_url": "https://api.github.com/users/armenzg/following{/other_user}", 181 | "gists_url": "https://api.github.com/users/armenzg/gists{/gist_id}", 182 | "starred_url": "https://api.github.com/users/armenzg/starred{/owner}{/repo}", 183 | "subscriptions_url": "https://api.github.com/users/armenzg/subscriptions", 184 | "organizations_url": "https://api.github.com/users/armenzg/orgs", 185 | "repos_url": "https://api.github.com/users/armenzg/repos", 186 | "events_url": "https://api.github.com/users/armenzg/events{/privacy}", 187 | "received_events_url": "https://api.github.com/users/armenzg/received_events", 188 | "type": "User", 189 | "site_admin": false 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /tests/test_github_sdk.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from datetime import datetime 5 | from unittest.mock import patch 6 | 7 | import pytest 8 | import requests 9 | import responses 10 | from freezegun import freeze_time 11 | from requests import HTTPError 12 | from sentry_sdk.utils import format_timestamp 13 | 14 | from src.github_sdk import GithubClient 15 | 16 | DSN = "https://foo@random.ingest.sentry.io/bar" 17 | TOKEN = "irrelevant" 18 | 19 | # Different versions of Python represent certain errors differently 20 | prepend = "" 21 | if sys.version_info[:2] >= (3, 10): 22 | prepend = "GithubClient." 23 | 24 | 25 | def test_job_without_steps(skipped_workflow): 26 | sdk = GithubClient(dsn=DSN, token=TOKEN) 27 | assert sdk.send_trace(skipped_workflow) == None 28 | 29 | 30 | def test_initialize_without_setting_dsn(): 31 | with pytest.raises(TypeError) as excinfo: 32 | GithubClient() 33 | (msg,) = excinfo.value.args 34 | assert ( 35 | msg 36 | == f"{prepend}__init__() missing 2 required positional arguments: 'token' and 'dsn'" 37 | ) 38 | 39 | 40 | def test_initialize_without_setting_token(): 41 | with pytest.raises(TypeError) as excinfo: 42 | GithubClient(dsn=DSN) 43 | (msg,) = excinfo.value.args 44 | assert msg == f"{prepend}__init__() missing 1 required positional argument: 'token'" 45 | 46 | 47 | @responses.activate 48 | def test_ensure_raise_error_on_github_api_failure(): 49 | """We want to delegate to the app using the SDK to handle the error.""" 50 | url = "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951" 51 | responses.get(url, json="{reason: Internal Server Error}", status=500) 52 | with pytest.raises(HTTPError) as excinfo: 53 | client = GithubClient(dsn=DSN, token=TOKEN) 54 | client._fetch_github(url) 55 | (msg,) = excinfo.value.args 56 | assert ( 57 | msg 58 | == "500 Server Error: Internal Server Error for url: https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951" 59 | ) 60 | 61 | 62 | @freeze_time() 63 | @responses.activate 64 | @patch("src.github_sdk.get_uuid") 65 | def test_trace_generation( 66 | mock_get_uuid, 67 | jobA_job, 68 | jobA_runs, 69 | jobA_workflow, 70 | jobA_trace, 71 | uuid_list, 72 | ): 73 | mock_get_uuid.side_effect = uuid_list 74 | responses.get( 75 | "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951", 76 | json=jobA_runs, 77 | ) 78 | responses.get( 79 | "https://api.github.com/repos/getsentry/sentry/actions/workflows/1174556", 80 | json=jobA_workflow, 81 | ) 82 | client = GithubClient(dsn=DSN, token=TOKEN) 83 | assert client._generate_trace(jobA_job) == jobA_trace 84 | 85 | 86 | @freeze_time() 87 | @responses.activate 88 | @patch("src.github_sdk.get_uuid") 89 | def test_send_trace( 90 | mock_get_uuid, 91 | jobA_job, 92 | jobA_runs, 93 | jobA_workflow, 94 | uuid_list, 95 | ): 96 | mock_get_uuid.side_effect = uuid_list 97 | responses.get( 98 | "https://api.github.com/repos/getsentry/sentry/actions/runs/2104746951", 99 | json=jobA_runs, 100 | ) 101 | responses.get( 102 | "https://api.github.com/repos/getsentry/sentry/actions/workflows/1174556", 103 | json=jobA_workflow, 104 | ) 105 | 106 | responses.post("https://foo@random.ingest.sentry.io/api/bar/envelope/") 107 | 108 | client = GithubClient(dsn=DSN, token=TOKEN) 109 | resp = client.send_trace(jobA_job) 110 | # This cannot happen in a fixture, otherwise, there will be a tiny bit of a clock drift 111 | now = datetime.utcnow() 112 | 113 | envelope_headers = { 114 | "User-Agent": f"python-requests/{requests.__version__}", 115 | "Accept-Encoding": "gzip, deflate", 116 | "Accept": "*/*", 117 | "Connection": "keep-alive", 118 | "event_id": "a401d83c7ec0495f82a8da8d9a389f5b", 119 | "sent_at": format_timestamp(now), 120 | "Content-Type": "application/x-sentry-envelope", 121 | "Content-Encoding": "gzip", 122 | "X-Sentry-Auth": f"Sentry sentry_key=foo,sentry_client=gha-sentry/0.0.1,sentry_timestamp={now},sentry_version=7", 123 | "Content-Length": "693", 124 | } 125 | 126 | for k, v in resp.request.headers.items(): 127 | assert envelope_headers[k] == v 128 | 129 | # XXX: We will deal with this another time 130 | # assert ( 131 | # resp.request.body 132 | # == b"\x1f\x8b\x08\x00\xf1\x16}b\x02\xff\xb5TM\x8f\xd30\x10\xbd\xef\xaf\x88|\x02\xa9m\x1c\xc7\x89\x93H\x08\xd0\x8a;\x12\x9c@\xa8\x9a\xd8\xe3&\xbb\xf9\"vX\xaa\xaa\xff\x1d{\xdb\xee\x97\x96n\xcb\x8aS\xd3\x99\xf1\xf8\xbd7o\xbc\xd9^l\x88]\x0fH\nbG\xe8\x0cH[\xf7\x1d\x99\x11\xd9w\x16;\xbb\xdc'a\x18\x9aZ\x82O\x86W\xe6\xb6\xa2\xc1ne+RD\x19\xa7\xbe\r\xfe\xf2\xf5\xb5r\xd5\t \x139H\x95g\x8c\xcbRC\x96P\xceh\x14K]\xea\xac\x14\xee\xf4\xb3\x97>\xfcW\x10=\xdebP\x81EcM\xf0\x86\xbe=\xe0\xfam\r)6\xbe\\\xa2\xff0\x03t\xbb\x9b\x81\xd3He\xb1\x14()\xcf\x13\xbdk*q\x97\xcd\xa2\x92\x96\x08)\xa0\xd2<\x8b\x04\x08\xaeb\xa6\x04Ms\x9a)\xcd\x1e\xe1r\xadgD\x81\x05\x7f\xc3U_\xbahe\xed`\x8a0\\\xd5\xb6\x9a\xca\x85\xec\xdbp\x85\xd68\xde\xe3:\xdc\xff\x8cSg\xc2$KD\xc2\x04O\xe8{Y\xa1\xbc^\x9a\xa9\xb6\xb8\xd4\xbd\x9c\xcc;;N\xbe\xf50\x9e\xd8q\x98\x9a&\x8c\xe3\x98\x0b\xb2\x9d\x91~\xf8\x9b4\n\x8d\x1c\xeb\xe1\x98z\xc6\x82\x9d\x9cv\xa4\xbf&[\xd7l28zz\x1d\xb4\x9e\xf5\xc7\xaaE\x15|\xb2\xa8\xd7\xae\x18[\xa8\x1b\xaf\xa9\x8f.\xd0G#\xf6a\xe5\xa3\x1e\xa8\x07\xe3\xfa\x8d\xce#u\xeb\xee\x80\xd6#c\x94\xb19\xe5s\x9a~\x8d\xf2\"aE$\xbeyY\x9f/a\xb4\xa0I\x11\xefJ`e\xf6R/\xefp\x9aIJ4\xc6\xa5K\xe7\rY\x1d\xe0\x84\xa0\xd4\xdc\xa1\xae\xbb\xd5\xbc\x81\xb5c\xe1\xad\xd1\xb6\xb5\xf5\xd4U.R\x16e\x90s\x95\x01\x95\x8aQ\xe7\x88(B\x10\xaa\xc44\x93\x89\x88Qs\xe5\xce\x8c8\xf4\xee\xc4S\xcd}f\xea\x96`-\xb6\x83k\x19\xcd\xc8M?^\xeb\xa6\xbf\xf1\x08\x1c\xa6\xc1:8\xb8X\xb7\x8d\x1f\xa5\x9b\xd0r\xc4\x9f\x93\xe3H\x8a\xdbQyq\x9c+\x1d\x87\xef\x9b\xdd\xcc\xbe\xa0\r\xa6!\xf0N\x9a\x1d\x04\x7f\x14\x1b`\xf4\x1bt\xd4\xcc\xf7I*X\xaaK\x0e,\xe6\x11\x17B\x92\xd3\xa6\x91.(\xa5G&\xb2+c\xf4\xae\xec\x8c\xed\xd9\xce\xf6T?;=\x82U%\xc7E\xdd?\xf0\xf3n\xb3\xe7\x95m\x9b\xb9\xed\xe7u\x0b+,\x1a\xf0\x06\xbd\x97\xe4\x9f\xce\x9e'\x1d\x08!RT\x11S\x00\x9c\xe7\xe5i\xd2=\xd0\xe4XY\x92\xbdN\xba\xde\xd8\xe0\xd2\xbf\x19\xfdd\x83;7\x1e\xc4y>{\x1e\xfd\x14\x9da\xb4\x8e\x05\xd39\xcf3x\x89\xfeaI_\xa0\xbf/K^E\xff\xb2o\x87\xc6=5\x8f\xd7\xe4I\xf4<\xba\x8e\xa2\xdbWT\x98\xe8\x88\xbb\xa7\xe1D\xba\xc9\xff\xa4\xfbc{\xf1\x07Hk>,{\x07\x00\x00" 133 | # ) 134 | -------------------------------------------------------------------------------- /tests/test_sentry_config_file.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from unittest import TestCase 4 | 5 | import responses 6 | 7 | from src.sentry_config import fetch_dsn_for_github_org 8 | from src.sentry_config import SENTRY_CONFIG_API_URL as api_url 9 | 10 | expected_dsn = ( 11 | "https://73805ee0a679438d909bb0e6e05fb97f@o510822.ingest.sentry.io/6627507" 12 | ) 13 | sentry_config_file_meta = { 14 | "name": "sentry_config.ini", 15 | "path": "sentry_config.ini", 16 | "sha": "21a49641b349f82af39b3ef5f54613a556e60fcc", 17 | "size": 619, 18 | "url": "https://api.github.com/repos/armenzg/.sentry/contents/sentry_config.ini?ref=main", 19 | "html_url": "https://github.com/armenzg/.sentry/blob/main/sentry_config.ini", 20 | "git_url": "https://api.github.com/repos/armenzg/.sentry/git/blobs/21a49641b349f82af39b3ef5f54613a556e60fcc", 21 | "download_url": "https://raw.githubusercontent.com/armenzg/.sentry/main/sentry_config.ini?token=A2NWFUXUKJYCKJQXPAUSHULC6KTXLAVPNFXHG5DBNRWGC5DJN5XF62LEZYA2YZLLWFUW443UMFWGYYLUNFXW4X3UPFYGLN2JNZ2GKZ3SMF2GS33OJFXHG5DBNRWGC5DJN5XA", 22 | "type": "file", 23 | "content": "OyBUaGlzIGZpbGUgbmVlZHMgdG8gYmUgcGxhY2VkIHdpdGhpbiBhIHByaXZh\ndGUgcmVwb3NpdG9yeSBuYW1lZCAuc2VudHJ5IChpdCBjYW4gYWxzbyBiZSBt\nYWRlIHB1YmxpYykKOyBUaGlzIGZpbGUgZm9yIG5vdyB3aWxsIGNvbmZpZ3Vy\nZSB0aGUgc2VudHJ5LWdpdGh1Yi1hY3Rpb25zLWFwcAo7IGJ1dCBpdCBtYXli\nZSB1c2VkIGJ5IGZ1dHVyZSBTZW50cnkgc2VydmljZXMKCjsgVGhpcyBjb25m\naWd1cmVzIGh0dHBzOi8vZ2l0aHViLmNvbS9nZXRzZW50cnkvc2VudHJ5LWdp\ndGh1Yi1hY3Rpb25zLWFwcAo7IEZvciBub3cgaXQgaXMgb25seSB1c2VkIHRv\nIGNvbmZpZ3VyZSB0aGUgcHJvamVjdCB5b3Ugd2FudCB5b3VyIG9yZydzIENJ\nCjsgdG8gcG9zdCB0cmFuc2FjdGlvbnMgdG8KW3NlbnRyeS1naXRodWItYWN0\naW9ucy1hcHBdCjsgVGhpcyBwcm9qZWN0IGlzIHVuZGVyIHNlbnRyeS1lY29z\neXN0ZW0gb3JnCjsgaHR0cHM6Ly9zZW50cnkuaW8vb3JnYW5pemF0aW9ucy9z\nZW50cnktZWNvc3lzdGVtL3BlcmZvcm1hbmNlLz9wcm9qZWN0PTY2Mjc1MDcK\nZHNuID0gaHR0cHM6Ly83MzgwNWVlMGE2Nzk0MzhkOTA5YmIwZTZlMDVmYjk3\nZkBvNTEwODIyLmluZ2VzdC5zZW50cnkuaW8vNjYyNzUwNw==\n", 24 | "encoding": "base64", 25 | "_links": { 26 | "self": "https://api.github.com/repos/armenzg/.sentry/contents/sentry_config.ini?ref=main", 27 | "git": "https://api.github.com/repos/armenzg/.sentry/git/blobs/21a49641b349f82af39b3ef5f54613a556e60fcc", 28 | "html": "https://github.com/armenzg/.sentry/blob/main/sentry_config.ini", 29 | }, 30 | } 31 | org = "armenzg" 32 | token = "foo_token" 33 | 34 | 35 | class TestSentryConfigCase(TestCase): 36 | def setUp(self) -> None: 37 | self.api_url = api_url.replace("{owner}", org) 38 | responses.add( 39 | method="GET", 40 | url=self.api_url, 41 | json=sentry_config_file_meta, 42 | status=200, 43 | ) 44 | return super().setUp() 45 | 46 | @responses.activate 47 | def test_fetch_parse_sentry_config_file(self) -> None: 48 | assert fetch_dsn_for_github_org(org, token) == expected_dsn 49 | 50 | def test_fetch_private_repo(self) -> None: 51 | pass 52 | 53 | def test_file_missing(self) -> None: 54 | pass 55 | 56 | def test_bad_contents(self) -> None: 57 | pass 58 | -------------------------------------------------------------------------------- /tests/test_web_app_handler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from unittest import mock 5 | 6 | import pytest 7 | 8 | from src.web_app_handler import WebAppHandler 9 | 10 | valid_signature = "d9259f51d3b64e7fe0cbe09d9b08b8ee763170d3521fecc35fd8b453be8cf6a5" 11 | 12 | 13 | def test_invalid_header(): 14 | handler = WebAppHandler() 15 | # This is missing X-GitHub-Event in the headers 16 | with pytest.raises(KeyError) as excinfo: 17 | handler.handle_event(data={}, headers={}) 18 | (msg,) = excinfo.value.args 19 | assert msg == "X-GitHub-Event" 20 | 21 | 22 | # XXX: These tests could be covered with a JSON schema 23 | def test_invalid_github_event(): 24 | handler = WebAppHandler() 25 | # This has an invalid X-GitHub-Event value 26 | reason, http_code = handler.handle_event( 27 | data={}, 28 | headers={"X-GitHub-Event": "not_a_workflow_job"}, 29 | ) 30 | assert reason == "Event not supported." 31 | assert http_code == 200 32 | 33 | 34 | def test_missing_action_key(): 35 | handler = WebAppHandler() 36 | # This payload is missing the action key 37 | with pytest.raises(KeyError) as excinfo: 38 | handler.handle_event( 39 | data={"bad_key": "irrelevant"}, 40 | headers={"X-GitHub-Event": "workflow_job"}, 41 | ) 42 | (msg,) = excinfo.value.args 43 | assert msg == "action" 44 | 45 | 46 | def test_not_completed_workflow(): 47 | handler = WebAppHandler() 48 | # This payload has an an action state we cannot process 49 | reason, http_code = handler.handle_event( 50 | data={"action": "not_completed"}, 51 | headers={"X-GitHub-Event": "workflow_job"}, 52 | ) 53 | assert reason == "We cannot do anything with this workflow state." 54 | assert http_code == 200 55 | 56 | 57 | @pytest.mark.skip(reason="Not so important") 58 | def test_missing_workflow_job(monkeypatch): 59 | monkeypatch.delenv("GH_APP_ID", raising=False) 60 | handler = WebAppHandler() 61 | # This tries to send a trace but we're missing the workflow_job key 62 | with pytest.raises(KeyError) as excinfo: 63 | handler.handle_event( 64 | data={"action": "completed"}, 65 | headers={"X-GitHub-Event": "workflow_job"}, 66 | ) 67 | (msg,) = excinfo.value.args 68 | assert msg == "workflow_job" 69 | 70 | 71 | def test_valid_signature_no_secret(monkeypatch): 72 | monkeypatch.delenv("GH_WEBHOOK_SECRET", raising=False) 73 | handler = WebAppHandler() 74 | assert handler.valid_signature(body={}, headers={}) == True 75 | 76 | 77 | def test_valid_signature(monkeypatch, webhook_event): 78 | monkeypatch.setenv("GH_WEBHOOK_SECRET", "fake_secret") 79 | handler = WebAppHandler() 80 | assert ( 81 | handler.valid_signature( 82 | body=json.dumps(webhook_event["payload"]).encode(), 83 | headers={"X-Hub-Signature-256": f"sha256={valid_signature}"}, 84 | ) 85 | == True 86 | ) 87 | 88 | 89 | def test_invalid_signature(monkeypatch, webhook_event): 90 | monkeypatch.setenv("GH_WEBHOOK_SECRET", "mistyped_secret") 91 | handler = WebAppHandler() 92 | # This is unit testing that the function works as expected 93 | assert ( 94 | handler.valid_signature( 95 | body=json.dumps(webhook_event["payload"]).encode(), 96 | headers={"X-Hub-Signature-256": f"sha256={valid_signature}"}, 97 | ) 98 | == False 99 | ) 100 | 101 | 102 | def test_handle_event_with_secret(monkeypatch, webhook_event): 103 | monkeypatch.setenv("GH_WEBHOOK_SECRET", "fake_secret") 104 | handler = WebAppHandler(dry_run=True) 105 | reason, http_code = handler.handle_event( 106 | data=webhook_event["payload"], 107 | headers=webhook_event["headers"], 108 | ) 109 | assert reason == "OK" 110 | assert http_code == 200 111 | --------------------------------------------------------------------------------