├── .dockerignore ├── .env.sample ├── .flake8 ├── .github ├── CODEOWNERS ├── actions │ ├── setup-node │ │ └── action.yml │ └── setup-test │ │ └── action.yml └── workflows │ ├── backend-py.yml │ ├── backend-ts.yml │ ├── frontend-ts.yml │ └── lint.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── Makefile ├── README.md ├── backend-py ├── .envrc ├── .flake8 ├── Dockerfile ├── README.md ├── requirements.txt ├── src │ ├── __init__.py │ ├── api │ │ ├── __init__.py │ │ ├── endpoints │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── items.py │ │ │ ├── organizations.py │ │ │ ├── sentry │ │ │ │ ├── __init__.py │ │ │ │ ├── alert_rule_action.py │ │ │ │ ├── handlers │ │ │ │ │ ├── __init__.py │ │ │ │ │ ├── alert_handler.py │ │ │ │ │ ├── comment_handler.py │ │ │ │ │ └── issue_handler.py │ │ │ │ ├── issue_link.py │ │ │ │ ├── options.py │ │ │ │ ├── setup.py │ │ │ │ └── webhook.py │ │ │ └── users.py │ │ ├── errors │ │ │ └── __init__.py │ │ ├── middleware │ │ │ ├── __init__.py │ │ │ └── verify_sentry_signature.py │ │ ├── serializers │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── item.py │ │ │ ├── organization.py │ │ │ └── user.py │ │ └── validators │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── item.py │ │ │ ├── organization.py │ │ │ └── user.py │ ├── database.py │ ├── models │ │ ├── __init__.py │ │ ├── item.py │ │ ├── organization.py │ │ ├── sentry_installation.py │ │ └── user.py │ ├── server.py │ ├── types.py │ └── util │ │ └── sentry_api_client.py └── tests │ ├── __init__.py │ ├── api │ ├── __init__.py │ ├── base.py │ ├── items │ │ ├── __init__.py │ │ ├── base.py │ │ ├── test_delete.py │ │ ├── test_get.py │ │ ├── test_index.py │ │ ├── test_post.py │ │ └── test_put.py │ ├── organizations │ │ ├── __init__.py │ │ ├── base.py │ │ ├── test_delete.py │ │ ├── test_get.py │ │ ├── test_index.py │ │ ├── test_post.py │ │ └── test_put.py │ ├── sentry │ │ ├── __init__.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── test_alert_handler.py │ │ │ ├── test_comment_handler.py │ │ │ └── test_issue_handler.py │ │ ├── test_alert_rule_action.py │ │ ├── test_issue_link.py │ │ ├── test_options.py │ │ ├── test_setup.py │ │ └── test_webhook.py │ ├── test_server.py │ └── users │ │ ├── __init__.py │ │ ├── base.py │ │ ├── test_delete.py │ │ ├── test_get.py │ │ ├── test_index.py │ │ ├── test_post.py │ │ └── test_put.py │ ├── conftest.py │ ├── fixtures.py │ └── mocks.py ├── backend-ts ├── .eslintrc.js ├── .prettierrc.js ├── Dockerfile ├── README.md ├── babel.config.js ├── jest.config.js ├── nodemon.json ├── package-lock.json ├── package.json ├── src │ ├── api │ │ ├── index.ts │ │ ├── items.ts │ │ ├── middleware │ │ │ ├── index.ts │ │ │ └── verifySentrySignature.ts │ │ ├── organizations.ts │ │ ├── sentry │ │ │ ├── alertRuleAction.ts │ │ │ ├── handlers │ │ │ │ ├── alertHandler.ts │ │ │ │ ├── commentHandler.ts │ │ │ │ └── issueHandler.ts │ │ │ ├── index.ts │ │ │ ├── issueLink.ts │ │ │ ├── options.ts │ │ │ ├── setup.ts │ │ │ └── webhook.ts │ │ └── users.ts │ ├── index.ts │ ├── models │ │ ├── Item.model.ts │ │ ├── Organization.model.ts │ │ ├── SentryInstallation.model.ts │ │ ├── User.model.ts │ │ └── index.ts │ ├── server.ts │ └── util │ │ └── SentryAPIClient.ts ├── tests │ ├── api │ │ ├── items.spec.ts │ │ ├── organizations.spec.ts │ │ ├── sentry │ │ │ ├── alertRuleAction.spec.ts │ │ │ ├── handlers │ │ │ │ ├── alertHandler.spec.ts │ │ │ │ ├── commentHandler.spec.ts │ │ │ │ └── issueHandler.spec.ts │ │ │ ├── issueLink.spec.ts │ │ │ ├── options.spec.ts │ │ │ ├── setup.spec.ts │ │ │ └── webhook.spec.ts │ │ └── users.spec.ts │ ├── factories │ │ ├── Item.factory.ts │ │ ├── Organization.factory.ts │ │ ├── SentryInstallation.factory.ts │ │ └── User.factory.ts │ ├── mocks.ts │ ├── server.spec.ts │ └── testutils.ts └── tsconfig.json ├── data ├── Dockerfile └── scripts │ ├── clear.sql │ └── schema.sql ├── docker-compose.yml ├── docs ├── api-usage.md ├── installation.md ├── reference-implementation-frontend.png ├── ui-components │ ├── alert-rule-actions.md │ └── issue-linking.md └── webhooks │ ├── alert-webhooks.md │ ├── comment-webhooks.md │ └── event-webhooks.md ├── frontend ├── .eslintrc.js ├── .prettierrc.js ├── Dockerfile ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html ├── src │ ├── App.tsx │ ├── components │ │ ├── BasePage.tsx │ │ ├── Button.tsx │ │ ├── Column.tsx │ │ ├── ErrorForm.tsx │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── ItemCard.tsx │ │ ├── ItemCardCommentSection.tsx │ │ ├── Main.tsx │ │ ├── SentryLogo.tsx │ │ └── ThemedSelect.tsx │ ├── index.tsx │ ├── pages │ │ ├── KanbanPage.tsx │ │ ├── LandingPage.tsx │ │ └── SetupPage.tsx │ ├── setupTests.ts │ ├── styles │ │ ├── GlobalStyles.tsx │ │ ├── emotion.d.ts │ │ └── theme.ts │ ├── tests │ │ ├── KanbanPage.spec.tsx │ │ ├── LandingPage.spec.tsx │ │ ├── SetupPage.spec.tsx │ │ └── testutil.tsx │ ├── types.ts │ └── util.ts └── tsconfig.json ├── integration-schema.json └── ngrok-config.example.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | # Python Ignores 2 | **/.venv 3 | **/.pytest_cache 4 | **/__pycache__ 5 | 6 | # Node Ignores 7 | **/node_modules 8 | **/dist 9 | 10 | # Security Ignores 11 | .env 12 | 13 | # Misc Ignores 14 | .git 15 | .vscode 16 | *.md 17 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Python Backend 2 | 3 | # Specify the Flask environment 4 | FLASK_ENV=development 5 | FLASK_APP=src 6 | # Specify the arguments for 'flask run' 7 | FLASK_RUN_HOST=0.0.0.0 8 | FLASK_RUN_PORT=5100 9 | 10 | # TypeScript Backend 11 | 12 | # Specify the Node environment 13 | NODE_ENV=development 14 | # Specify the Express listening port 15 | EXPRESS_LISTEN_PORT=5200 16 | 17 | # TypeScript Frontend 18 | # Note: Anything prefixed with REACT_APP_ is exposed! 19 | 20 | REACT_APP_PORT=3000 21 | REACT_APP_BACKEND_URL=REPLACE_ME_WITH_YOUR_NGROK_ADDRESS 22 | REACT_APP_SENTRY_DSN=REPLACE_ME_WITH_YOUR_DSN 23 | 24 | # Database 25 | 26 | # Specify access credentials and database 27 | POSTGRES_USER=admin 28 | POSTGRES_PASSWORD=needbetterpassword 29 | POSTGRES_DB=sentrydemo 30 | # Specify options 31 | DB_PORT=6000 32 | TEST_DB_PORT=6001 33 | 34 | # Misc 35 | 36 | # Specify the sentry instance 37 | SENTRY_URL=https://sentry.io 38 | # Specify the public integration details 39 | SENTRY_CLIENT_ID=REPLACE_ME_WITH_YOUR_CLIENT_ID 40 | SENTRY_CLIENT_SECRET=REPLACE_ME_WITH_YOUR_CLIENT_SECRET 41 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @getsentry/ecosystem 2 | -------------------------------------------------------------------------------- /.github/actions/setup-node/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Node" 2 | description: "Install and cache relevant node modules" 3 | 4 | inputs: 5 | project_directory: 6 | description: "Project directory with which to find and install dependencies" 7 | required: true 8 | 9 | runs: 10 | using: "composite" 11 | steps: 12 | - name: Cache Node Modules 13 | uses: actions/setup-node@v2 14 | with: 15 | node-version: '16' 16 | cache: 'npm' 17 | cache-dependency-path: ${{inputs.project_directory}}/package-lock.json 18 | 19 | - name: Install Node Modules 20 | working-directory: ${{inputs.project_directory}} 21 | shell: bash 22 | run: npm install 23 | -------------------------------------------------------------------------------- /.github/actions/setup-test/action.yml: -------------------------------------------------------------------------------- 1 | name: "Setup Test Database" 2 | description: "Build and serve a local testing database" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Use Sample ENV File 8 | run: mv .env.sample .env 9 | shell: bash 10 | 11 | - name: Build Test Database 12 | run: docker compose build test-database 13 | shell: bash 14 | 15 | - name: Serve Test Database 16 | run: docker compose up test-database --detach 17 | shell: bash 18 | -------------------------------------------------------------------------------- /.github/workflows/backend-py.yml: -------------------------------------------------------------------------------- 1 | name: backend-py 2 | on: 3 | pull_request: 4 | paths: 5 | - ".github/actions/setup-test" 6 | - ".github/workflows/backend-py.yml" 7 | - "backend-py/**" 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | timeout-minutes: 10 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.9" 22 | 23 | - uses: "./.github/actions/setup-test" 24 | 25 | - name: Install Python Dependecies 26 | working-directory: backend-py 27 | run: pip install -r requirements.txt 28 | 29 | - name: Run Tests 30 | working-directory: backend-py 31 | run: pytest 32 | 33 | docker: 34 | timeout-minutes: 10 35 | runs-on: ubuntu-latest 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v3 39 | 40 | - name: Test image starts up 41 | run: | 42 | cp .env.sample .env 43 | docker compose up -d frontend backend-py 44 | curl --request GET http://0.0.0.0:5100 || docker compose logs 45 | -------------------------------------------------------------------------------- /.github/workflows/backend-ts.yml: -------------------------------------------------------------------------------- 1 | name: backend-ts 2 | on: 3 | pull_request: 4 | paths: 5 | - ".github/actions/**" 6 | - ".github/workflows/backend-ts.yml" 7 | - "backend-ts/**" 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | timeout-minutes: 20 15 | runs-on: ubuntu-20.04 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: "./.github/actions/setup-node" 20 | with: 21 | project_directory: backend-ts 22 | - uses: "./.github/actions/setup-test" 23 | 24 | - name: Run Tests 25 | working-directory: backend-ts 26 | run: npm run test:ci 27 | 28 | docker: 29 | timeout-minutes: 10 30 | runs-on: ubuntu-20.04 31 | steps: 32 | - name: Checkout 33 | uses: actions/checkout@v3 34 | 35 | - name: Test image starts up 36 | run: | 37 | cp .env.sample .env 38 | docker compose up -d frontend backend-ts 39 | curl --request GET http://0.0.0.0:5200 || docker compose logs 40 | -------------------------------------------------------------------------------- /.github/workflows/frontend-ts.yml: -------------------------------------------------------------------------------- 1 | name: frontend 2 | on: 3 | pull_request: 4 | paths: 5 | - ".github/actions/**" 6 | - ".github/workflows/frontend-ts.yml" 7 | - "frontend/**" 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | timeout-minutes: 20 15 | runs-on: ubuntu-20.04 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: "./.github/actions/setup-node" 20 | with: 21 | project_directory: frontend 22 | 23 | - name: Run Tests 24 | working-directory: frontend 25 | run: npm run test 26 | 27 | docker: 28 | timeout-minutes: 10 29 | runs-on: ubuntu-20.04 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v3 33 | 34 | - name: Test image starts up 35 | run: | 36 | cp .env.sample .env 37 | docker compose up -d frontend backend-ts 38 | curl --request GET http://0.0.0.0:3000 || docker compose logs 39 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | timeout-minutes: 10 11 | runs-on: ubuntu-20.04 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | with: 17 | python-version: "3.9" 18 | 19 | - name: Install Python Dependecies 20 | working-directory: backend-py 21 | run: pip install "pre-commit==2.20.0" 22 | 23 | - name: Run Linting 24 | run: pre-commit run --all-files 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | .pnp 4 | .pnp.js 5 | __pycache__ 6 | .venv 7 | 8 | # Testing 9 | coverage 10 | 11 | # Production 12 | build 13 | dist 14 | 15 | # Secrets (shh!) 16 | .env 17 | ngrok-config.yml 18 | 19 | # Misc 20 | .DS_Store 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | .idea 26 | .vscode 27 | 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.2.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: debug-statements 9 | # - id: name-tests-test 10 | # args: ["--django"] 11 | - id: requirements-txt-fixer 12 | - repo: https://github.com/PyCQA/flake8 13 | rev: 4.0.1 14 | hooks: 15 | - id: flake8 16 | - repo: https://github.com/psf/black 17 | rev: 22.6.0 # Replace by any tag/version: https://github.com/psf/black/tags 18 | hooks: 19 | - id: black 20 | language_version: python3 21 | - repo: https://github.com/python-jsonschema/check-jsonschema 22 | rev: 0.16.0 23 | hooks: 24 | - id: check-github-workflows 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := help 2 | 3 | help: 4 | @echo 'Welcome to the Integration Platform Example a.k.a. ACME Kanban 🚀!' 5 | @echo 6 | @echo '>>> Quickstart' 7 | @echo 'make serve-python -> Start the python backend + frontend' 8 | @echo 'make serve-typescript -> Start the typescript backend + frontend' 9 | @echo 10 | @echo '>>> Debugging' 11 | @echo 'make setup-python -> Rebuild the python backend with updated dependencies and environment variables' 12 | @echo 'make setup-typescript -> Rebuild the typescript backend with updated dependencies and environment variables' 13 | @echo 'make seed-db -> Initialize the database with test data (Note: requires "make teardown" execution beforehand)' 14 | @echo 'make dump-db -> Replace the data in the seed file with the current database' 15 | @echo 'make reset-db -> Empty out the current database' 16 | @echo 'make teardown -> Stop all ongoing processes and remove their data (Note: erases the database)' 17 | @echo 18 | @echo '>>> Testing' 19 | @echo 'make setup-tests -> Starts the test database' 20 | 21 | # Quickstart 22 | 23 | serve-python: 24 | docker compose up --build frontend backend-py 25 | 26 | serve-typescript: 27 | docker compose up --build frontend backend-ts 28 | 29 | # Debugging 30 | 31 | setup-python: 32 | docker compose build frontend backend-py 33 | 34 | setup-typescript: 35 | docker compose build frontend backend-ts 36 | 37 | seed-db: 38 | docker exec database bash -c 'cat scripts/schema.sql | psql $$POSTGRES_DB -U $$POSTGRES_USER' 39 | 40 | dump-db: 41 | docker exec database bash -c 'pg_dump $$POSTGRES_DB -U $$POSTGRES_USER > scripts/schema.sql' 42 | 43 | reset-db: 44 | docker exec database bash -c 'cat scripts/clear.sql | psql $$POSTGRES_DB -U $$POSTGRES_USER' 45 | 46 | teardown: 47 | docker compose down -v --remove-orphans --rmi all 48 | 49 | # Testing 50 | 51 | setup-tests: 52 | docker compose up test-database --detach 53 | -------------------------------------------------------------------------------- /backend-py/.envrc: -------------------------------------------------------------------------------- 1 | source ".venv/bin/activate" 2 | 3 | unset PS1 4 | -------------------------------------------------------------------------------- /backend-py/.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 100 3 | -------------------------------------------------------------------------------- /backend-py/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim as backend-py 2 | 3 | RUN apt update && \ 4 | apt install -y python3-dev gcc libpq-dev 5 | 6 | WORKDIR /backend-py 7 | # Re-create the requirements layer if the requirements change 8 | COPY requirements.txt . 9 | RUN pip install -r requirements.txt 10 | 11 | # Copy the rest of the source code now, otherwise, code changes will invalidate the requirements cache 12 | COPY . . 13 | # Preparing startup... 14 | CMD ["flask", "run"] 15 | -------------------------------------------------------------------------------- /backend-py/README.md: -------------------------------------------------------------------------------- 1 | # Backend - Python 2 | 3 | This directory contains the backend code written in Python (with Flask and SQLAlchemy). 4 | 5 | ## Development 6 | 7 | To start, you'll need to install [Docker](https://docs.docker.com/engine/install/) and ensure it is running. 8 | 9 | Then, to spin up this service: 10 | 11 | ```bash 12 | docker compose up backend-py 13 | ``` 14 | 15 | If adding dependencies or changing the environment variables, be sure to setup a virtual environment and then rebuild the image. We suggest using [direnv](https://direnv.net/) when managing your virtual environment: 16 | 17 | ```bash 18 | python3 -m venv .venv 19 | direnv allow 20 | (.venv) pip install -r requirements.txt 21 | (.venv) pip install my-package 22 | (.venv) pip freeze > requirements.txt 23 | docker compose build backend-py 24 | ``` 25 | 26 | ## Testing 27 | 28 | To check for linting errors, run the following in this directory: 29 | 30 | ```bash 31 | flake8 src 32 | ``` 33 | 34 | To run all tests, run the following commands: 35 | 36 | ```bash 37 | make setup-tests 38 | cd backend-py 39 | pytest 40 | ``` 41 | -------------------------------------------------------------------------------- /backend-py/requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==21.4.0 2 | black==22.1.0 3 | certifi==2021.10.8 4 | charset-normalizer==2.0.12 5 | click==8.0.4 6 | flake8==4.0.1 7 | Flask==2.0.3 8 | Flask-Cors==3.0.10 9 | Flask-SQLAlchemy==2.5.1 10 | greenlet==1.1.2 11 | idna==3.3 12 | iniconfig==1.1.1 13 | itsdangerous==2.1.0 14 | Jinja2==3.0.3 15 | MarkupSafe==2.1.0 16 | mccabe==0.6.1 17 | mypy-extensions==0.4.3 18 | packaging==21.3 19 | pathspec==0.9.0 20 | platformdirs==2.5.1 21 | pluggy==1.0.0 22 | psycopg2==2.9.3 23 | py==1.11.0 24 | pycodestyle==2.8.0 25 | pyflakes==2.4.0 26 | pyparsing==3.0.7 27 | pytest==7.0.1 28 | python-dotenv==0.19.2 29 | requests==2.27.1 30 | responses==0.20.0 31 | six==1.16.0 32 | SQLAlchemy==1.4.32 33 | tomli==2.0.1 34 | typing_extensions==4.1.1 35 | urllib3==1.26.9 36 | Werkzeug==2.0.3 37 | -------------------------------------------------------------------------------- /backend-py/src/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import app 2 | from . import database # NOQA 3 | 4 | # Register routes and serializers. 5 | with app.app_context(): 6 | from .api import endpoints # NOQA 7 | from .api import serializers # NOQA 8 | 9 | __all__ = ("app",) 10 | -------------------------------------------------------------------------------- /backend-py/src/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/integration-platform-example/10cf516b6bb68f5055e6c0f6ce84c07530a8c267/backend-py/src/api/__init__.py -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | from . import items # NOQA 2 | from . import organizations # NOQA 3 | from . import users # NOQA 4 | from .sentry import alert_rule_action # NOQA 5 | from .sentry import issue_link # NOQA 6 | from .sentry import options # NOQA 7 | from .sentry import setup # NOQA 8 | from .sentry import webhook # NOQA 9 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/base.py: -------------------------------------------------------------------------------- 1 | from flask.views import View 2 | 3 | from src import app 4 | 5 | 6 | def register_api( 7 | view: View, 8 | endpoint: str, 9 | url: str, 10 | pk: str = "id", 11 | pk_type: str = "int", 12 | ) -> None: 13 | """See https://flask.palletsprojects.com/en/2.0.x/views/.""" 14 | view_func = view.as_view(endpoint) 15 | app.add_url_rule( 16 | url, 17 | defaults={pk: None}, 18 | view_func=view_func, 19 | methods=[ 20 | "GET", 21 | ], 22 | ) 23 | app.add_url_rule( 24 | url, 25 | view_func=view_func, 26 | methods=[ 27 | "POST", 28 | ], 29 | ) 30 | app.add_url_rule( 31 | f"{url}<{pk_type}:{pk}>", view_func=view_func, methods=["GET", "PUT", "DELETE"] 32 | ) 33 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/items.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import jsonify, request, Response 4 | from flask.views import MethodView 5 | from werkzeug.exceptions import NotFound 6 | 7 | from src.api.endpoints.base import register_api 8 | from src.api.serializers import serialize 9 | from src.api.validators import validate_new_item, validate_item_update 10 | from src.database import db_session 11 | from src.models import Item, Organization 12 | from src.util.sentry_api_client import SentryAPIClient 13 | 14 | 15 | def add_sentry_api_data( 16 | organization: Organization, 17 | query: Item.query, 18 | ): 19 | # Create an APIClient to talk to Sentry 20 | sentry = SentryAPIClient.create(organization) 21 | items = serialize(query.all()) 22 | for item in items: 23 | if item["sentryId"]: 24 | # Use the numerical ID to fetch the short ID 25 | sentry_data = sentry.get( 26 | f"/organizations/{organization.external_slug}/issues/{item['sentryId']}/" 27 | ) 28 | short_id = sentry_data.json().get("shortId") 29 | # Replace the numerical ID with the short ID 30 | if short_id: 31 | item["sentryId"] = short_id 32 | return jsonify(items) 33 | 34 | 35 | class ItemAPI(MethodView): 36 | def _get_item_or_404(self, item_id: int) -> Item: 37 | item = Item.query.filter(Item.id == item_id).first() 38 | if not item: 39 | raise NotFound 40 | return item 41 | 42 | def index(self) -> Response: 43 | organization_slug = request.args.get("organization") 44 | user_id = request.args.get("user") 45 | 46 | query = Item.query 47 | 48 | if organization_slug: 49 | organization_option = Organization.query.filter( 50 | Organization.slug == organization_slug 51 | ).first() 52 | if organization_option: 53 | query = query.filter(Item.organization_id == organization_option.id) 54 | linked_query = query.filter(Item.sentry_id is not None) 55 | if linked_query.count() > 0: 56 | return add_sentry_api_data(organization_option, query) 57 | 58 | if user_id is not None: 59 | query = query.filter(Item.assignee_id == user_id) 60 | 61 | return jsonify(serialize(query.all())) 62 | 63 | def get(self, item_id: int) -> Response: 64 | if item_id is None: 65 | return self.index() 66 | 67 | item = self._get_item_or_404(item_id) 68 | return serialize(item) 69 | 70 | def post(self) -> Response: 71 | item = Item(**validate_new_item(request.json)) 72 | db_session.add(item) 73 | db_session.commit() 74 | 75 | response = jsonify(serialize(item)) 76 | response.status_code = 201 77 | return response 78 | 79 | def put(self, item_id: int) -> Response: 80 | item = self._get_item_or_404(item_id) 81 | 82 | for key, value in validate_item_update(request.json).items(): 83 | setattr(item, key, value) 84 | 85 | db_session.commit() 86 | response = jsonify(serialize(item)) 87 | response.status_code = 204 88 | return response 89 | 90 | def delete(self, item_id: int) -> Response: 91 | item = self._get_item_or_404(item_id) 92 | 93 | db_session.delete(item) 94 | db_session.commit() 95 | 96 | return Response(status=204) 97 | 98 | 99 | register_api(ItemAPI, "item_api", "/api/items/", pk="item_id") 100 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/organizations.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import jsonify, request, Response 4 | from flask.views import MethodView 5 | from sqlalchemy.exc import IntegrityError 6 | from werkzeug.exceptions import BadRequest, NotFound 7 | 8 | from src.api.endpoints.base import register_api 9 | from src.api.serializers import serialize 10 | from src.api.validators import validate_new_organization, validate_organization_update 11 | from src.database import db_session 12 | from src.models import Organization 13 | 14 | 15 | class OrganizationAPI(MethodView): 16 | def _get_organization_or_404(self, organization_slug: str) -> Organization: 17 | organization = Organization.query.filter( 18 | Organization.slug == organization_slug 19 | ).first() 20 | if not organization: 21 | raise NotFound 22 | return organization 23 | 24 | def index(self) -> Response: 25 | return jsonify(serialize(Organization.query.all())) 26 | 27 | def get(self, organization_slug: str) -> Response: 28 | if organization_slug is None: 29 | return self.index() 30 | 31 | organization = self._get_organization_or_404(organization_slug) 32 | return serialize(organization) 33 | 34 | def post(self) -> Response: 35 | organization = Organization(**validate_new_organization(request.json)) 36 | db_session.add(organization) 37 | 38 | try: 39 | db_session.commit() 40 | except IntegrityError: 41 | raise BadRequest("Invalid: property 'slug' must be unique") 42 | 43 | response = jsonify(serialize(organization)) 44 | response.status_code = 201 45 | return response 46 | 47 | def put(self, organization_slug: str) -> Response: 48 | organization = self._get_organization_or_404(organization_slug) 49 | 50 | for key, value in validate_organization_update(request.json).items(): 51 | setattr(organization, key, value) 52 | 53 | db_session.commit() 54 | response = jsonify(serialize(organization)) 55 | response.status_code = 204 56 | return response 57 | 58 | def delete(self, organization_slug: str) -> Response: 59 | organization = self._get_organization_or_404(organization_slug) 60 | 61 | db_session.delete(organization) 62 | db_session.commit() 63 | 64 | return Response(status=204) 65 | 66 | 67 | register_api( 68 | OrganizationAPI, 69 | "organization_api", 70 | "/api/organizations/", 71 | pk="organization_slug", 72 | pk_type="string", 73 | ) 74 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/sentry/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/integration-platform-example/10cf516b6bb68f5055e6c0f6ce84c07530a8c267/backend-py/src/api/endpoints/sentry/__init__.py -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/sentry/alert_rule_action.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, request, Response 2 | from functools import reduce 3 | from typing import Sequence, TypedDict, Any 4 | 5 | from src import app 6 | from src.api.middleware import verify_sentry_signature 7 | from src.models import SentryInstallation, User 8 | 9 | 10 | class AlertRuleSettings(TypedDict): 11 | """ 12 | The shape of your settings will depend on how you configure your form fields 13 | This example coordinates with integration-schema.json for 'alert-rule-settings' 14 | """ 15 | 16 | title: str 17 | description: str 18 | userId: str 19 | 20 | 21 | class SentryField(TypedDict): 22 | name: str 23 | value: Any 24 | 25 | 26 | def convert_sentry_fields_to_dict(fields: Sequence[SentryField]) -> AlertRuleSettings: 27 | return reduce( 28 | lambda acc, field: {**acc, field.get("name"): field.get("value")}, fields, {} 29 | ) 30 | 31 | 32 | # This endpoint will only be called if the 'alert-rule-action' is present in the schema. 33 | @app.route("/api/sentry/alert-rule-action/", methods=["POST"]) 34 | @verify_sentry_signature() 35 | def alert_rule_action() -> Response: 36 | uuid = request.json.get("installationId") 37 | installation = SentryInstallation.query.filter( 38 | SentryInstallation.uuid == uuid 39 | ).first() 40 | if not installation: 41 | return jsonify({"message": "Invalid installation was provided"}), 400 42 | 43 | # Now we can validate the data the user provided to our alert rule action 44 | # Sending a payload with the 'message' key will be surfaced to the user in Sentry 45 | # This stops the user from creating the alert, so it's a good way to bubble up relevant info. 46 | alert_rule_action_settings = convert_sentry_fields_to_dict( 47 | request.json.get("fields") 48 | ) 49 | if not alert_rule_action_settings.get( 50 | "title" 51 | ) or not alert_rule_action_settings.get("description"): 52 | return jsonify({"message": "Title and description are required"}), 400 53 | 54 | if alert_rule_action_settings.get("userId"): 55 | user = User.query.filter( 56 | User.id == alert_rule_action_settings.get("userId") 57 | ).first() 58 | if not user: 59 | return jsonify({"message": "Selected user was not found"}), 400 60 | 61 | app.logger.info("Successfully validated Sentry alert rule") 62 | 63 | # By sending a successful response code, we are approving that alert to notify our application. 64 | return Response("", 200) 65 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/sentry/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .alert_handler import alert_handler 2 | from .comment_handler import comment_handler 3 | from .issue_handler import issue_handler 4 | 5 | __all__ = ("alert_handler", "comment_handler", "issue_handler") 6 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/sentry/handlers/comment_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Mapping 2 | from flask import Response 3 | 4 | from src import app 5 | from src.models import Item, SentryInstallation 6 | from src.models.item import ItemComment 7 | from src.database import db_session 8 | 9 | 10 | def handle_created(item: Item, comment: ItemComment) -> Response: 11 | item.comments = (item.comments or []) + [comment] 12 | db_session.commit() 13 | app.logger.info("Added new comment from Sentry issue") 14 | return Response("", 201) 15 | 16 | 17 | def handle_updated(item: Item, comment: ItemComment) -> Response: 18 | item.comments = [ 19 | comment 20 | if existing_comment["sentryCommentId"] == comment["sentryCommentId"] 21 | else existing_comment 22 | for existing_comment in item.comments or [] 23 | ] 24 | db_session.commit() 25 | app.logger.info("Updated comment from Sentry issue") 26 | return Response("", 200) 27 | 28 | 29 | def handle_deleted(item: Item, comment: ItemComment) -> Response: 30 | item.comments = list( 31 | filter( 32 | lambda existing_comment: existing_comment["sentryCommentId"] 33 | != comment["sentryCommentId"], 34 | item.comments or [], 35 | ) 36 | ) 37 | db_session.commit() 38 | app.logger.info("Deleted comment from Sentry issue") 39 | return Response("", 204) 40 | 41 | 42 | def comment_handler( 43 | action: str, 44 | sentry_installation: SentryInstallation, 45 | data: Mapping[str, Any], 46 | actor: Mapping[str, Any], 47 | ) -> Response: 48 | item = Item.query.filter( 49 | Item.sentry_id == str(data["issue_id"]), 50 | Item.organization_id == sentry_installation.organization_id, 51 | ).first() 52 | if item is None: 53 | app.logger.info("Ignoring comment for unlinked Sentry issue") 54 | return Response("", 200) 55 | 56 | # In your application you may want to map Sentry user IDs (actor.id) to your internal user IDs 57 | # for a richer comment sync experience 58 | incoming_comment = { 59 | "text": data["comment"], 60 | "author": actor["name"], 61 | "timestamp": data["timestamp"], 62 | "sentryCommentId": data["comment_id"], 63 | } 64 | 65 | if action == "created": 66 | return handle_created(item, incoming_comment) 67 | elif action == "updated": 68 | return handle_updated(item, incoming_comment) 69 | elif action == "deleted": 70 | return handle_deleted(item, incoming_comment) 71 | else: 72 | app.logger.info(f"Unexpected Sentry comment action: {action}") 73 | return Response("", 400) 74 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/sentry/options.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | from src import app 5 | from src.api.middleware import verify_sentry_signature 6 | from src.models import SentryInstallation, Item, User 7 | 8 | from flask import jsonify, request, Response 9 | 10 | 11 | # These endpoints are used to populate the options for 'Select' FormFields in Sentry. 12 | 13 | 14 | @app.route("/api/sentry/options/items/", methods=["GET"]) 15 | @verify_sentry_signature() 16 | def get_item_options() -> Response: 17 | uuid = request.args.get("installationId") 18 | sentry_installation = SentryInstallation.query.filter( 19 | SentryInstallation.uuid == uuid 20 | ).first() 21 | if not sentry_installation: 22 | return Response("", 404) 23 | # We can use the installation data to filter the items we return to Sentry. 24 | items = Item.query.filter( 25 | Item.organization_id == sentry_installation.organization_id 26 | ).all() 27 | # Sentry requires the results in this exact format. 28 | result = [{"value": item.id, "label": item.title} for item in items] 29 | app.logger.info("Populating item options in Sentry") 30 | return jsonify(result) 31 | 32 | 33 | @app.route("/api/sentry/options/users/", methods=["GET"]) 34 | @verify_sentry_signature() 35 | def get_user_options() -> Response: 36 | uuid = request.args.get("installationId") 37 | sentry_installation = SentryInstallation.query.filter( 38 | SentryInstallation.uuid == uuid 39 | ).first() 40 | if not sentry_installation: 41 | return Response("", 404) 42 | # We can use the installation data to filter the users we return to Sentry. 43 | users = User.query.filter( 44 | User.organization_id == sentry_installation.organization_id 45 | ).all() 46 | # Sentry requires the results in this exact format. 47 | result = [{"value": user.id, "label": user.name} for user in users] 48 | app.logger.info("Populating user options in Sentry") 49 | return jsonify(result) 50 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/sentry/setup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | 5 | import requests 6 | from dotenv import load_dotenv 7 | from flask import request 8 | 9 | from src import app 10 | from src.database import db_session 11 | from src.models import SentryInstallation, Organization 12 | 13 | load_dotenv() 14 | SENTRY_CLIENT_ID = os.getenv("SENTRY_CLIENT_ID") 15 | SENTRY_CLIENT_SECRET = os.getenv("SENTRY_CLIENT_SECRET") 16 | SENTRY_URL = os.getenv("SENTRY_URL") 17 | 18 | 19 | @app.route("/api/sentry/setup/", methods=["POST"]) 20 | def setup_index(): 21 | # Get the query params from the installation prompt. 22 | code = request.json.get("code") 23 | uuid = request.json.get("installationId") 24 | sentry_org_slug = request.json.get("sentryOrgSlug") 25 | organization_id = request.json.get("organizationId") 26 | 27 | # Construct a payload to ask Sentry for a token on the basis that a user is installing. 28 | payload = { 29 | "grant_type": "authorization_code", 30 | "code": code, 31 | "client_id": SENTRY_CLIENT_ID, 32 | "client_secret": SENTRY_CLIENT_SECRET, 33 | } 34 | # Send that payload to Sentry and parse its response. 35 | token_response = requests.post( 36 | f"{SENTRY_URL}/api/0/sentry-app-installations/{uuid}/authorizations/", 37 | json=payload, 38 | ) 39 | 40 | # Get the JSON body fields from the auth call. 41 | token_data = token_response.json() 42 | token = token_data.get("token") 43 | refresh_token = token_data.get("refreshToken") 44 | expires_at = token_data.get("expiresAt") 45 | 46 | # Store the token data (i.e. token, refreshToken, expiresAt) for future requests. 47 | # - Make sure to associate the installationId and the tokenData since it's 48 | # unique to the organization. 49 | # - Using the wrong token for a different installation will result 401 Unauthorized responses. 50 | organization = Organization.query.filter(Organization.id == organization_id).first() 51 | installation = SentryInstallation( 52 | uuid=uuid, 53 | org_slug=sentry_org_slug, 54 | token=token, 55 | refresh_token=refresh_token, 56 | expires_at=expires_at, 57 | organization_id=organization.id, 58 | ) 59 | db_session.add(installation) 60 | db_session.commit() 61 | 62 | # Verify the installation to inform Sentry of the success. 63 | # - This step is only required if you have enabled 'Verify Installation' on your integration. 64 | verify_response = requests.put( 65 | f"{SENTRY_URL}/api/0/sentry-app-installations/{uuid}/", 66 | json={"status": "installed"}, 67 | headers={"Authorization": f"Bearer {token}"}, 68 | ) 69 | 70 | # Get the JSON body fields from the verify call. 71 | verify_data = verify_response.json() 72 | app_slug = verify_data.get("app")["slug"] 73 | 74 | # Update the associated organization to connect it to Sentry's organization 75 | organization.external_slug = sentry_org_slug 76 | db_session.commit() 77 | 78 | # Continue the installation process. 79 | # - If your app requires additional configuration, do it here. 80 | # - The token/refreshToken can be used to make requests to Sentry's API 81 | # - You can optionally redirect the user back to Sentry as we do below. 82 | app.logger.info(f"Installed {app_slug} on '{organization.name}'") 83 | return { 84 | "redirectUrl": f"{SENTRY_URL}/settings/{sentry_org_slug}/sentry-apps/{app_slug}/" 85 | }, 201 86 | -------------------------------------------------------------------------------- /backend-py/src/api/endpoints/users.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from flask import jsonify, request, Response 4 | from flask.views import MethodView 5 | from werkzeug.exceptions import NotFound 6 | 7 | from src.api.endpoints.base import register_api 8 | from src.api.serializers import serialize 9 | from src.api.validators import validate_new_user, validate_user_update 10 | from src.database import db_session 11 | from src.models import Organization, User 12 | 13 | 14 | class UserAPI(MethodView): 15 | def _get_user_or_404(self, user_id: int) -> User: 16 | user = User.query.filter(User.id == user_id).first() 17 | if not user: 18 | raise NotFound 19 | return user 20 | 21 | def index(self) -> Response: 22 | organization_slug = request.args.get("organization") 23 | 24 | query = User.query 25 | 26 | if organization_slug is not None: 27 | organization_option = Organization.query.filter( 28 | Organization.slug == organization_slug 29 | ).first() 30 | if organization_option: 31 | query = query.filter(User.organization_id == organization_option.id) 32 | 33 | return jsonify(serialize(query.all())) 34 | 35 | def get(self, user_id: int) -> Response: 36 | if user_id is None: 37 | return self.index() 38 | 39 | user = self._get_user_or_404(user_id) 40 | return serialize(user) 41 | 42 | def post(self) -> Response: 43 | user = User(**validate_new_user(request.json)) 44 | db_session.add(user) 45 | db_session.commit() 46 | 47 | response = jsonify(serialize(user)) 48 | response.status_code = 201 49 | return response 50 | 51 | def put(self, user_id: int) -> Response: 52 | user = self._get_user_or_404(user_id) 53 | 54 | for key, value in validate_user_update(request.json).items(): 55 | setattr(user, key, value) 56 | 57 | db_session.commit() 58 | response = jsonify(serialize(user)) 59 | response.status_code = 204 60 | return response 61 | 62 | def delete(self, user_id: int) -> Response: 63 | user = self._get_user_or_404(user_id) 64 | 65 | db_session.delete(user) 66 | db_session.commit() 67 | 68 | return Response(status=204) 69 | 70 | 71 | register_api(UserAPI, "user_api", "/api/users/", pk="user_id") 72 | -------------------------------------------------------------------------------- /backend-py/src/api/errors/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import jsonify, Response 2 | from werkzeug.exceptions import NotFound 3 | 4 | from src import app 5 | 6 | 7 | @app.errorhandler(NotFound) 8 | def handle_not_found(error) -> Response: 9 | response = jsonify(error) 10 | response.status_code = 400 11 | return response 12 | -------------------------------------------------------------------------------- /backend-py/src/api/middleware/__init__.py: -------------------------------------------------------------------------------- 1 | from .verify_sentry_signature import verify_sentry_signature 2 | 3 | __all__ = ("verify_sentry_signature",) 4 | -------------------------------------------------------------------------------- /backend-py/src/api/middleware/verify_sentry_signature.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import hmac 5 | import os 6 | import functools 7 | from typing import Any, Mapping 8 | 9 | from dotenv import load_dotenv 10 | from flask import request, Response 11 | 12 | from src import app 13 | 14 | load_dotenv() 15 | FLASK_ENV = os.getenv("FLASK_ENV") 16 | SENTRY_CLIENT_SECRET = os.getenv("SENTRY_CLIENT_SECRET") 17 | 18 | # There are few hacks in this verification step (denoted with HACK) that we at Sentry hope 19 | # to migrate away from in the future. Presently however, for legacy reasons, they are 20 | # necessary to keep around, so we've shown how to deal with them here. 21 | 22 | 23 | def is_correct_sentry_signature( 24 | body: Mapping[str, Any], key: str, expected: str 25 | ) -> bool: 26 | # expected could be `None` if the header was missing, 27 | # in which case we return early as the request is invalid 28 | # without a signature 29 | if not expected: 30 | return False 31 | 32 | digest = hmac.new( 33 | key=key.encode("utf-8"), 34 | msg=body, 35 | digestmod=hashlib.sha256, 36 | ).hexdigest() 37 | 38 | if not hmac.compare_digest(digest, expected): 39 | return False 40 | 41 | app.logger.info("Authorized: Verified request came from Sentry") 42 | return True 43 | 44 | 45 | def verify_sentry_signature(): 46 | """ 47 | This function will authenticate that the requests are coming from Sentry. 48 | It allows us to be confident that all the code run after this middleware are 49 | using verified data sent directly from Sentry. 50 | """ 51 | 52 | def wrapper(f): 53 | @functools.wraps(f) 54 | def inner(*args: Any, **kwargs: Any): 55 | # HACK: We need to use the raw request body since Flask will throw a 400 Bad Request 56 | # if we try to use request.json. This is because Sentry sends an empty body (i.e. b'') 57 | # with a Content-Type of application/json for some requests. 58 | raw_body = request.get_data() 59 | if ( 60 | FLASK_ENV != "test" 61 | # HACK: The signature header may be one of these two values 62 | and not is_correct_sentry_signature( 63 | body=raw_body, 64 | key=SENTRY_CLIENT_SECRET, 65 | expected=request.headers.get("sentry-hook-signature"), 66 | ) 67 | and not is_correct_sentry_signature( 68 | body=raw_body, 69 | key=SENTRY_CLIENT_SECRET, 70 | expected=request.headers.get("sentry-app-signature"), 71 | ) 72 | ): 73 | app.logger.info( 74 | "Unauthorized: Could not verify request came from Sentry" 75 | ) 76 | return Response("", 401) 77 | return f(*args, **kwargs) 78 | 79 | return inner 80 | 81 | return wrapper 82 | -------------------------------------------------------------------------------- /backend-py/src/api/serializers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import register, serialize, BaseSerializer 2 | 3 | __all__ = ( 4 | "register", 5 | "serialize", 6 | "BaseSerializer", 7 | ) 8 | 9 | # Register the serializers. 10 | from . import item # NOQA 11 | from . import organization # NOQA 12 | from . import user # NOQA 13 | -------------------------------------------------------------------------------- /backend-py/src/api/serializers/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | from typing import Any, Callable, MutableMapping, Sequence, Type, TypeVar 5 | 6 | from src.database import Base 7 | from src.types import JSONData 8 | 9 | 10 | K = TypeVar("K") 11 | 12 | registry: MutableMapping[Any, Any] = {} 13 | 14 | 15 | def register(type: Any) -> Callable[[Type[K]], Type[K]]: 16 | """ 17 | A wrapper that adds the wrapped Serializer to the Serializer registry (see 18 | above) for the key `type`. 19 | """ 20 | 21 | def wrapped(cls: Type[K]) -> Type[K]: 22 | registry[type] = cls() 23 | return cls 24 | 25 | return wrapped 26 | 27 | 28 | def serialize( 29 | objects: Base | Sequence[Base], **kwargs: Any 30 | ) -> JSONData | Sequence[JSONData]: 31 | """ 32 | Turn a model (or list of models) into a python object made entirely of primitives. 33 | 34 | :param objects: A list of objects 35 | :param kwargs Any 36 | :returns A list of the serialized versions of `objects`. 37 | """ 38 | if not objects: 39 | return objects 40 | 41 | if not isinstance(objects, list): 42 | return registry[type(objects)].serialize(objects, **kwargs) 43 | 44 | serializer = registry[type(objects[0])] 45 | return [serializer.serialize(o, **kwargs) for o in objects] 46 | 47 | 48 | class BaseSerializer(abc.ABC): 49 | """A Serializer class contains the logic to serialize a specific type of object.""" 50 | 51 | @abc.abstractmethod 52 | def serialize( 53 | self, objects: Base | Sequence[Base], **kwargs: Any 54 | ) -> MutableMapping[str, JSONData]: 55 | pass 56 | -------------------------------------------------------------------------------- /backend-py/src/api/serializers/item.py: -------------------------------------------------------------------------------- 1 | from src.api.serializers import register 2 | from src.models import Item 3 | from src.types import JSONData 4 | 5 | 6 | @register(Item) 7 | class ItemSerializer: 8 | def serialize(self, obj) -> JSONData: 9 | return { 10 | "id": obj.id, 11 | "assigneeId": obj.assignee_id, 12 | "column": obj.column.value, 13 | "complexity": obj.complexity, 14 | "description": obj.description, 15 | "sentryId": obj.sentry_id, 16 | "sentryAlertId": obj.sentry_alert_id, 17 | "comments": obj.comments, 18 | "isIgnored": obj.is_ignored, 19 | "organizationId": obj.organization_id, 20 | "title": obj.title, 21 | } 22 | -------------------------------------------------------------------------------- /backend-py/src/api/serializers/organization.py: -------------------------------------------------------------------------------- 1 | from src.api.serializers import register 2 | from src.models import Organization 3 | from src.types import JSONData 4 | 5 | 6 | @register(Organization) 7 | class OrganizationSerializer: 8 | def serialize(self, obj) -> JSONData: 9 | return { 10 | "id": obj.id, 11 | "name": obj.name, 12 | "slug": obj.slug, 13 | "externalSlug": obj.slug, 14 | } 15 | -------------------------------------------------------------------------------- /backend-py/src/api/serializers/user.py: -------------------------------------------------------------------------------- 1 | from src.api.serializers import register 2 | from src.models import User 3 | from src.types import JSONData 4 | 5 | 6 | @register(User) 7 | class UserSerializer: 8 | def serialize(self, obj) -> JSONData: 9 | return { 10 | "id": obj.id, 11 | "name": obj.name, 12 | "username": obj.username, 13 | "avatar": obj.avatar, 14 | "organizationId": obj.organization_id, 15 | } 16 | -------------------------------------------------------------------------------- /backend-py/src/api/validators/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ( 2 | validate_id, 3 | validate_integer, 4 | validate_organization, 5 | validate_optional_id, 6 | validate_optional_integer, 7 | validate_optional_str, 8 | validate_str, 9 | ) 10 | from .item import validate_new_item, validate_item_update 11 | from .user import validate_new_user, validate_user_update 12 | from .organization import validate_new_organization, validate_organization_update 13 | 14 | __all__ = ( 15 | "validate_id", 16 | "validate_integer", 17 | "validate_item_update", 18 | "validate_new_item", 19 | "validate_optional_id", 20 | "validate_optional_integer", 21 | "validate_optional_str", 22 | "validate_organization", 23 | "validate_str", 24 | "validate_new_user", 25 | "validate_user_update", 26 | "validate_new_organization", 27 | "validate_organization_update", 28 | ) 29 | -------------------------------------------------------------------------------- /backend-py/src/api/validators/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from werkzeug.exceptions import BadRequest 4 | 5 | STR_LENGTH_MAX = 256 6 | STR_LENGTH_MIN = 0 7 | 8 | 9 | def validate_id(value: int | str, name: str) -> int: 10 | try: 11 | id_value = int(value) 12 | except (TypeError, ValueError): 13 | raise BadRequest(f"Invalid: ID field '{name}' must be an integer") 14 | 15 | if id_value <= 0: 16 | raise BadRequest(f"Invalid: ID field '{name}' must be a positive integer") 17 | 18 | return id_value 19 | 20 | 21 | def validate_integer( 22 | value: int | str | None, 23 | name: str, 24 | minimum: int | None = None, 25 | maximum: int | None = None, 26 | ) -> int: 27 | try: 28 | int_value = int(value) 29 | except ValueError: 30 | raise BadRequest(f"Invalid: field '{name}' must be an integer") 31 | 32 | if not (minimum <= int_value <= maximum): 33 | raise BadRequest( 34 | f"Invalid: field '{name}' must be between {minimum} and {maximum}" 35 | ) 36 | return int_value 37 | 38 | 39 | def validate_str(value: str, name: str) -> str: 40 | if not (STR_LENGTH_MIN <= len(value) <= STR_LENGTH_MAX): 41 | raise BadRequest( 42 | f"Invalid: field '{name}' must be between {STR_LENGTH_MIN} and" 43 | f" {STR_LENGTH_MAX} characters" 44 | ) 45 | return value 46 | 47 | 48 | def validate_optional_id(value: int | str | None, name: str) -> int | None: 49 | if value is None: 50 | return None 51 | 52 | return validate_id(value, name) 53 | 54 | 55 | def validate_optional_integer( 56 | value: int | str | None, 57 | name: str, 58 | minimum: int | None = None, 59 | maximum: int | None = None, 60 | ) -> int | None: 61 | if value is None: 62 | return None 63 | 64 | return validate_integer(value, name, minimum, maximum) 65 | 66 | 67 | def validate_optional_str(value: str | None, name: str) -> str | None: 68 | if value is None: 69 | return None 70 | 71 | return validate_str(value, name) 72 | 73 | 74 | def validate_organization(value: int | str | None) -> int: 75 | return validate_id(value, "organizationId") 76 | -------------------------------------------------------------------------------- /backend-py/src/api/validators/item.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Mapping 4 | 5 | from werkzeug.exceptions import BadRequest 6 | 7 | from src.types import ItemColumn 8 | 9 | from . import ( 10 | validate_optional_id, 11 | validate_optional_integer, 12 | validate_optional_str, 13 | validate_organization, 14 | ) 15 | 16 | 17 | COMPLEXITY_MAX = 9000 18 | COMPLEXITY_MIN = 0 19 | 20 | 21 | def validate_assignee(value: int | str | None) -> int | None: 22 | return validate_optional_id(value, "assigneeId") 23 | 24 | 25 | def validate_column(value: str | None) -> str: 26 | if value is None: 27 | return ItemColumn.Todo 28 | try: 29 | return ItemColumn(value.upper()) 30 | except ValueError: 31 | raise BadRequest( 32 | f"Invalid: field 'column' must be one of {[e.value for e in ItemColumn]}" 33 | ) 34 | 35 | 36 | def validate_complexity(value: int | str | None) -> int: 37 | return ( 38 | validate_optional_integer(value, "complexity", COMPLEXITY_MIN, COMPLEXITY_MAX) 39 | or COMPLEXITY_MIN 40 | ) 41 | 42 | 43 | def validate_new_item(data: Mapping[str, Any]) -> Mapping[str, Any]: 44 | if not data: 45 | raise BadRequest("Invalid: POST data must not be empty") 46 | 47 | assignee_id = validate_assignee(data.get("assigneeId")) 48 | column = validate_column(data.get("column")) 49 | complexity = validate_complexity(data.get("complexity")) 50 | organization_id = validate_organization(data.get("organizationId")) 51 | 52 | title = validate_optional_str(data.get("title"), "title") 53 | description = validate_optional_str(data.get("description"), "description") 54 | 55 | return dict( 56 | assignee_id=assignee_id, 57 | column=column, 58 | complexity=complexity, 59 | description=description, 60 | organization_id=organization_id, 61 | title=title, 62 | ) 63 | 64 | 65 | def validate_item_update(data: Mapping[str, Any]) -> Mapping[str, Any]: 66 | data = data or {} 67 | output = dict() 68 | 69 | if "assigneeId" in data: 70 | output["assignee_id"] = validate_assignee(data.get("assigneeId")) 71 | 72 | if "column" in data: 73 | output["column"] = validate_column(data.get("column")) 74 | 75 | if "complexity" in data: 76 | output["complexity"] = validate_complexity(data.get("complexity")) 77 | 78 | if "title" in data: 79 | output["title"] = validate_optional_str(data.get("title"), "title") 80 | 81 | if "description" in data: 82 | output["description"] = validate_optional_str( 83 | data.get("description"), "description" 84 | ) 85 | 86 | if not output: 87 | raise BadRequest("Invalid: PUT data must not be empty") 88 | 89 | return output 90 | -------------------------------------------------------------------------------- /backend-py/src/api/validators/organization.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import Any, Mapping 5 | 6 | from werkzeug.exceptions import BadRequest 7 | 8 | from . import validate_optional_str, validate_str 9 | 10 | 11 | def validate_slug(value: str | None) -> str: 12 | slug = re.sub(r"\s+", "-", (value or "").lower().strip()) 13 | if not slug: 14 | raise BadRequest("Invalid: field 'slug' must not be empty") 15 | 16 | return validate_str(slug, "slug") 17 | 18 | 19 | def validate_new_organization(data: Mapping[str, Any]) -> Mapping[str, Any]: 20 | if not data: 21 | raise BadRequest("Invalid: POST data must not be empty") 22 | 23 | slug = validate_slug(data.get("slug")) 24 | external_slug = validate_optional_str(data.get("externalSlug"), "externalSlug") 25 | name = validate_optional_str(data.get("name"), "name") 26 | 27 | return dict(name=name, slug=slug, external_slug=external_slug) 28 | 29 | 30 | def validate_organization_update(data: Mapping[str, Any]) -> Mapping[str, Any]: 31 | data = data or {} 32 | output = dict() 33 | 34 | if "name" in data: 35 | output["name"] = validate_optional_str(data.get("name"), "name") 36 | 37 | if "externalSlug" in data: 38 | output["external_slug"] = validate_optional_str( 39 | data.get("externalSlug"), "externalSlug" 40 | ) 41 | 42 | if not output: 43 | raise BadRequest("Invalid: PUT data must not be empty") 44 | 45 | return output 46 | -------------------------------------------------------------------------------- /backend-py/src/api/validators/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Mapping 4 | 5 | from werkzeug.exceptions import BadRequest 6 | 7 | from . import validate_optional_str, validate_organization 8 | 9 | 10 | def validate_new_user(data: Mapping[str, Any]) -> Mapping[str, Any]: 11 | if not data: 12 | raise BadRequest("Invalid: POST data must not be empty") 13 | 14 | name = validate_optional_str(data.get("name"), "name") 15 | username = validate_optional_str(data.get("name"), "username") 16 | avatar = validate_optional_str(data.get("name"), "avatar") 17 | organization_id = validate_organization(data.get("organizationId")) 18 | 19 | return dict( 20 | name=name, 21 | username=username, 22 | avatar=avatar, 23 | organization_id=organization_id, 24 | ) 25 | 26 | 27 | def validate_user_update(data: Mapping[str, Any]) -> Mapping[str, Any]: 28 | data = data or {} 29 | output = dict() 30 | 31 | if "name" in data: 32 | output["name"] = validate_optional_str(data.get("name"), "name") 33 | 34 | if "username" in data: 35 | output["username"] = validate_optional_str(data.get("username"), "username") 36 | 37 | if "avatar" in data: 38 | output["avatar"] = validate_optional_str(data.get("description"), "avatar") 39 | 40 | if not output: 41 | raise BadRequest("Invalid: PUT data must not be empty") 42 | 43 | return output 44 | -------------------------------------------------------------------------------- /backend-py/src/database.py: -------------------------------------------------------------------------------- 1 | import os 2 | import contextlib 3 | from sqlalchemy import create_engine 4 | from sqlalchemy.orm import scoped_session, sessionmaker 5 | from sqlalchemy.ext.declarative import declarative_base 6 | 7 | from src import app 8 | 9 | engine = create_engine(app.config["DATABASE"]) 10 | db_session = scoped_session( 11 | sessionmaker( 12 | autocommit=False, 13 | autoflush=False, 14 | bind=engine, 15 | # Disable object expiration to make testing with fixtures easier 16 | expire_on_commit=os.getenv("FLASK_ENV") != "test", 17 | ) 18 | ) 19 | 20 | Base = declarative_base() 21 | Base.query = db_session.query_property() 22 | 23 | 24 | def init_db(): 25 | from . import models # NOQA 26 | 27 | Base.metadata.create_all(bind=engine) 28 | 29 | 30 | def clear_tables(): 31 | with contextlib.closing(engine.connect()) as connection: 32 | transaction = connection.begin() 33 | for table in reversed(Base.metadata.sorted_tables): 34 | connection.execute(table.delete()) 35 | transaction.commit() 36 | 37 | 38 | def drop_tables(): 39 | Base.metadata.drop_all(bind=engine) 40 | 41 | 42 | @app.teardown_appcontext 43 | def shutdown_session(exception=None): 44 | db_session.remove() 45 | 46 | 47 | init_db() 48 | -------------------------------------------------------------------------------- /backend-py/src/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .item import Item 2 | from .organization import Organization 3 | from .sentry_installation import SentryInstallation 4 | from .user import User 5 | 6 | __all__ = ("Item", "Organization", "SentryInstallation", "User") 7 | -------------------------------------------------------------------------------- /backend-py/src/models/item.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import Sequence 3 | 4 | from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Enum, JSON 5 | 6 | from .. import database 7 | from ..types import ItemComment, ItemColumn 8 | 9 | 10 | class Item(database.Base): 11 | __tablename__ = "item" 12 | 13 | id = Column(Integer, primary_key=True) 14 | title = Column(String) 15 | description = Column(String) 16 | complexity = Column(Integer) 17 | column = Column( 18 | Enum(ItemColumn, values_callable=lambda x: [e.value for e in x]), 19 | default=ItemColumn.Todo, 20 | nullable=False, 21 | ) 22 | is_ignored = Column(Boolean, default=False) 23 | sentry_id = Column(String) 24 | sentry_alert_id = Column(String) 25 | comments = Column(JSON) 26 | assignee_id = Column(Integer, ForeignKey("user.id")) 27 | organization_id = Column(Integer, ForeignKey("organization.id")) 28 | 29 | def __init__( 30 | self, 31 | title: str, 32 | organization_id: int, 33 | assignee_id: int | None = None, 34 | description: str | None = None, 35 | complexity: int | None = None, 36 | column: str | None = None, 37 | sentry_id: str | None = None, 38 | sentry_alert_id: str | None = None, 39 | comments: Sequence[ItemComment] | None = [], 40 | is_ignored: bool | None = False, 41 | ): 42 | self.title = title 43 | self.description = description 44 | self.complexity = complexity 45 | self.column = column 46 | self.organization_id = organization_id 47 | self.assignee_id = assignee_id 48 | self.sentry_id = sentry_id 49 | self.sentry_alert_id = sentry_alert_id 50 | self.comments = comments 51 | self.is_ignored = is_ignored 52 | 53 | def __repr__(self): 54 | return f"" 55 | -------------------------------------------------------------------------------- /backend-py/src/models/organization.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy import Column, Integer, String 4 | from sqlalchemy.orm import relationship 5 | 6 | from .. import database 7 | 8 | 9 | class Organization(database.Base): 10 | __tablename__ = "organization" 11 | 12 | id = Column(Integer, primary_key=True) 13 | name = Column(String, nullable=False) 14 | slug = Column(String, nullable=False) 15 | external_slug = Column(String) 16 | 17 | items = relationship("Item") 18 | users = relationship("User") 19 | sentry_installations = relationship("SentryInstallation") 20 | 21 | def __init__(self, name: str, slug: str, external_slug: str) -> None: 22 | self.name = name 23 | self.slug = slug 24 | self.external_slug = external_slug 25 | 26 | def __repr__(self) -> str: 27 | return f"" 28 | -------------------------------------------------------------------------------- /backend-py/src/models/sentry_installation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | 5 | from sqlalchemy import Column, Integer, ForeignKey, String, DateTime 6 | 7 | from .. import database 8 | 9 | 10 | class SentryInstallation(database.Base): 11 | __tablename__ = "sentry_installation" 12 | 13 | id = Column(Integer, primary_key=True) 14 | uuid = Column(String, nullable=False) 15 | org_slug = Column(String, nullable=False) 16 | token = Column(String, nullable=False) 17 | refresh_token = Column(String, nullable=False) 18 | organization_id = Column(Integer, ForeignKey("organization.id")) 19 | expires_at = Column(DateTime) 20 | 21 | def __init__( 22 | self, 23 | uuid: str, 24 | org_slug: str, 25 | token: str, 26 | refresh_token: str, 27 | expires_at: datetime | None = None, 28 | organization_id: int | None = None, 29 | ): 30 | self.uuid = uuid 31 | self.org_slug = org_slug 32 | self.token = token 33 | self.refresh_token = refresh_token 34 | self.expires_at = expires_at 35 | self.organization_id = organization_id 36 | 37 | def __repr__(self): 38 | return f"" 39 | -------------------------------------------------------------------------------- /backend-py/src/models/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy import Column, Integer, ForeignKey, String 4 | from sqlalchemy.orm import relationship 5 | from .. import database 6 | 7 | 8 | class User(database.Base): 9 | __tablename__ = "user" 10 | 11 | id = Column(Integer, primary_key=True) 12 | name = Column(String) 13 | username = Column(String, nullable=False) 14 | avatar = Column(String) 15 | organization_id = Column(Integer, ForeignKey("organization.id")) 16 | 17 | items = relationship("Item") 18 | 19 | def __init__( 20 | self, 21 | username: str, 22 | organization_id: int, 23 | name: str | None = None, 24 | avatar: str | None = None, 25 | ): 26 | self.name = name 27 | self.username = username 28 | self.avatar = avatar 29 | self.organization_id = organization_id 30 | 31 | def __repr__(self): 32 | return f"" 33 | -------------------------------------------------------------------------------- /backend-py/src/server.py: -------------------------------------------------------------------------------- 1 | from flask_cors import CORS 2 | import os 3 | from dotenv import load_dotenv 4 | from flask import Flask 5 | 6 | load_dotenv() 7 | USER = os.getenv("POSTGRES_USER") 8 | PASSWORD = os.getenv("POSTGRES_PASSWORD") 9 | DB = os.getenv("POSTGRES_DB") 10 | FLASK_ENV = os.getenv("FLASK_ENV") 11 | 12 | if FLASK_ENV == "test": 13 | HOST = "localhost" 14 | PORT = 6001 15 | else: 16 | HOST = "database" 17 | PORT = 5432 18 | 19 | DATABASE = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DB}" 20 | 21 | 22 | def create_app(config=None): 23 | flask_app = Flask(__name__, instance_relative_config=True) 24 | CORS(flask_app) 25 | flask_app.config.from_mapping(config or {"DATABASE": DATABASE}) 26 | return flask_app 27 | 28 | 29 | app = create_app() 30 | 31 | __all__ = ("app",) 32 | -------------------------------------------------------------------------------- /backend-py/src/types.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from typing import Any, MutableMapping, TypedDict 3 | 4 | JSONData = MutableMapping[str, Any] 5 | 6 | 7 | class ItemComment(TypedDict): 8 | text: str 9 | author: str 10 | timestamp: str 11 | sentryCommentId: str 12 | 13 | 14 | class ItemColumn(enum.Enum): 15 | Todo = "TODO" 16 | Doing = "DOING" 17 | Done = "DONE" 18 | -------------------------------------------------------------------------------- /backend-py/src/util/sentry_api_client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import requests 5 | 6 | from dotenv import load_dotenv 7 | from datetime import datetime 8 | 9 | from src import app 10 | from src.database import db_session 11 | from src.models import Organization, SentryInstallation 12 | 13 | load_dotenv() 14 | 15 | 16 | class SentryAPIClient: 17 | def __init__(self, token): 18 | self.token = token 19 | 20 | @staticmethod 21 | def get_sentry_api_token(organization: Organization) -> str: 22 | """ 23 | Fetches an organization's Sentry API token, refreshing it if necessary. 24 | """ 25 | sentry_installation = SentryInstallation.query.filter( 26 | SentryInstallation.organization_id == organization.id 27 | ).first() 28 | 29 | # If the token is not expired, no need to refresh it 30 | if sentry_installation.expires_at.timestamp() > datetime.now().timestamp(): 31 | return sentry_installation.token 32 | 33 | # If the token is expired, we'll need to refresh it... 34 | app.logger.info( 35 | f"Token for {sentry_installation.org_slug} has expired. Refreshing..." 36 | ) 37 | # Construct a payload to ask Sentry for a new token 38 | payload = { 39 | "grant_type": "refresh_token", 40 | "refresh_token": sentry_installation.refresh_token, 41 | "client_id": os.getenv("SENTRY_CLIENT_ID"), 42 | "client_secret": os.getenv("SENTRY_CLIENT_SECRET"), 43 | } 44 | 45 | # Send that payload to Sentry and parse the response 46 | token_response = requests.post( 47 | url=( 48 | f"{os.getenv('SENTRY_URL')}/api/0/sentry-app-installations/" 49 | f"{sentry_installation.uuid}/authorizations/" 50 | ), 51 | json=payload, 52 | ).json() 53 | 54 | # Store the token information for future requests 55 | sentry_installation.token = token_response["token"] 56 | sentry_installation.refresh_token = token_response["refreshToken"] 57 | sentry_installation.expires_at = token_response["expiresAt"] 58 | db_session.commit() 59 | app.logger.info( 60 | f"Token for '{sentry_installation.org_slug}' has been refreshed." 61 | ) 62 | 63 | # Return the newly refreshed token 64 | return sentry_installation.token 65 | 66 | # We create a static wrapper on the constructor to ensure our token is always refreshed 67 | @staticmethod 68 | def create(organization: Organization) -> "SentryAPIClient": 69 | token = SentryAPIClient.get_sentry_api_token(organization) 70 | return SentryAPIClient(token) 71 | 72 | def request( 73 | self, method: str, path: str, data: dict | None = None 74 | ) -> requests.Response: 75 | response = requests.request( 76 | method=method, 77 | url=f"{os.getenv('SENTRY_URL')}/api/0{path}", 78 | headers={"Authorization": f"Bearer {self.token}"}, 79 | data=data, 80 | ) 81 | try: 82 | response.raise_for_status() 83 | except requests.exceptions.HTTPError as e: 84 | # TODO(you): Catch these sorta errors in Sentry! 85 | app.logger.error(f"Error while making a request to Sentry: {e}") 86 | return response 87 | 88 | def get(self, path: str) -> requests.Response: 89 | return self.request("GET", path) 90 | 91 | # TODO(you): Extend as you see fit! 92 | -------------------------------------------------------------------------------- /backend-py/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/integration-platform-example/10cf516b6bb68f5055e6c0f6ce84c07530a8c267/backend-py/tests/__init__.py -------------------------------------------------------------------------------- /backend-py/tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import APITestCase 2 | from src.database import init_db, drop_tables 3 | 4 | __all__ = ("APITestCase",) 5 | 6 | drop_tables() 7 | init_db() 8 | -------------------------------------------------------------------------------- /backend-py/tests/api/items/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ItemsApiTestBase 2 | 3 | __all__ = ("ItemsApiTestBase",) 4 | -------------------------------------------------------------------------------- /backend-py/tests/api/items/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from tests.api import APITestCase 4 | 5 | 6 | class ItemsApiTestBase(APITestCase): 7 | endpoint = "item_api" 8 | 9 | def setUp(self): 10 | super().setUp() 11 | 12 | self.organization = self.create_organization() 13 | self.user = self.create_user(self.organization) 14 | self.item = self.create_item(self.organization, self.user) 15 | self.sentry_installation = self.create_sentry_installation(self.organization) 16 | -------------------------------------------------------------------------------- /backend-py/tests/api/items/test_delete.py: -------------------------------------------------------------------------------- 1 | from . import ItemsApiTestBase 2 | 3 | from src.models import Item 4 | 5 | 6 | class ItemsApiDeleteTest(ItemsApiTestBase): 7 | method = "delete" 8 | 9 | def test_delete(self): 10 | self.get_success_response(item_id=self.item.id) 11 | assert Item.query.count() == 0 12 | 13 | def test_delete_not_found(self): 14 | self.get_error_response(item_id=0, status_code=404) 15 | -------------------------------------------------------------------------------- /backend-py/tests/api/items/test_get.py: -------------------------------------------------------------------------------- 1 | from . import ItemsApiTestBase 2 | 3 | 4 | class ItemsApiGetTest(ItemsApiTestBase): 5 | def test_get_not_found(self): 6 | self.get_error_response(item_id=0, status_code=404) 7 | 8 | def test_get(self): 9 | item = self.create_item(self.organization, self.user, title="unique title") 10 | 11 | response = self.get_success_response(item_id=item.id) 12 | assert response.json["title"] == item.title 13 | -------------------------------------------------------------------------------- /backend-py/tests/api/items/test_index.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import responses 4 | from dotenv import load_dotenv 5 | 6 | from . import ItemsApiTestBase 7 | from src.database import db_session 8 | 9 | 10 | load_dotenv() 11 | SENTRY_URL = os.getenv("SENTRY_URL") 12 | 13 | 14 | class ItemsApiIndexTest(ItemsApiTestBase): 15 | def setUp(self): 16 | super().setUp() 17 | 18 | other_organization = self.create_organization("other") 19 | other_user = self.create_user(other_organization, "other user") 20 | self.create_item(other_organization, other_user) 21 | 22 | def test_index(self): 23 | response = self.get_success_response() 24 | assert len(response.json) == 2 25 | 26 | def test_index_user(self): 27 | response = self.get_success_response(user=self.user.id) 28 | 29 | assert len(response.json) == 1 30 | assert response.json[0]["assigneeId"] == self.user.id 31 | 32 | def test_index_organization(self): 33 | response = self.get_success_response(organization=self.organization.slug) 34 | assert len(response.json) == 1 35 | assert response.json[0]["organizationId"] == self.organization.id 36 | 37 | @responses.activate 38 | def test_index_with_sentry_api(self): 39 | sentry_id = "12345" 40 | short_id = "PROJ-123" 41 | 42 | responses.add( 43 | responses.GET, 44 | f"{SENTRY_URL}/api/0/organizations/organization/issues/{sentry_id}/", 45 | body=json.dumps({"shortId": short_id}), 46 | ) 47 | 48 | self.item.sentry_id = sentry_id 49 | db_session.commit() 50 | response = self.get_success_response(organization=self.organization.slug) 51 | assert len(response.json) == 1 52 | assert response.json[0]["sentryId"] == short_id 53 | 54 | @responses.activate 55 | def test_index_with_failing_sentry_api(self): 56 | sentry_id = "12345" 57 | 58 | responses.add( 59 | responses.GET, 60 | f"{SENTRY_URL}/api/0/organizations/organization/issues/{sentry_id}/", 61 | body=json.dumps({}), 62 | ) 63 | 64 | self.item.sentry_id = sentry_id 65 | db_session.commit() 66 | response = self.get_success_response(organization=self.organization.slug) 67 | assert len(response.json) == 1 68 | assert response.json[0]["sentryId"] == sentry_id 69 | -------------------------------------------------------------------------------- /backend-py/tests/api/items/test_post.py: -------------------------------------------------------------------------------- 1 | from src.models import Item 2 | 3 | from . import ItemsApiTestBase 4 | 5 | 6 | class ItemsApiPostTest(ItemsApiTestBase): 7 | method = "post" 8 | 9 | def test_post(self): 10 | new_title = "New Title" 11 | new_data = {"title": new_title, "organizationId": self.organization.id} 12 | 13 | response = self.get_success_response(data=new_data) 14 | assert response.json["title"] == new_title 15 | 16 | item_id = response.json["id"] 17 | assert Item.query.filter(Item.id == item_id).first() 18 | 19 | def test_post_invalid(self): 20 | new_data = {"organizationId": "invalid"} 21 | 22 | self.get_error_response(data=new_data, status_code=400) 23 | -------------------------------------------------------------------------------- /backend-py/tests/api/items/test_put.py: -------------------------------------------------------------------------------- 1 | from src.models import Item 2 | 3 | from . import ItemsApiTestBase 4 | 5 | 6 | class ItemsApiPutTest(ItemsApiTestBase): 7 | method = "put" 8 | 9 | def test_put(self): 10 | new_title = "New Title" 11 | new_data = {"title": new_title} 12 | self.get_success_response(item_id=self.item.id, data=new_data) 13 | 14 | item = Item.query.filter(Item.id == self.item.id).first() 15 | assert item.title == "New Title" 16 | 17 | def test_put_empty(self): 18 | self.get_error_response(item_id=self.item.id, status_code=400) 19 | 20 | def test_put_invalid(self): 21 | invalid_data = {"assigneeId": "invalid"} 22 | self.get_error_response( 23 | item_id=self.item.id, data=invalid_data, status_code=400 24 | ) 25 | -------------------------------------------------------------------------------- /backend-py/tests/api/organizations/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import OrganizationsApiTestBase 2 | 3 | __all__ = ("OrganizationsApiTestBase",) 4 | -------------------------------------------------------------------------------- /backend-py/tests/api/organizations/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from tests.api import APITestCase 4 | 5 | 6 | class OrganizationsApiTestBase(APITestCase): 7 | endpoint = "organization_api" 8 | 9 | def setUp(self): 10 | super().setUp() 11 | 12 | self.organization = self.create_organization() 13 | -------------------------------------------------------------------------------- /backend-py/tests/api/organizations/test_delete.py: -------------------------------------------------------------------------------- 1 | from . import OrganizationsApiTestBase 2 | 3 | from src.models import Organization 4 | 5 | 6 | class OrganizationsApiDeleteTest(OrganizationsApiTestBase): 7 | method = "delete" 8 | 9 | def test_delete(self): 10 | self.get_success_response(organization_slug=self.organization.slug) 11 | assert Organization.query.count() == 0 12 | 13 | def test_delete_not_found(self): 14 | self.get_error_response(organization_slug="invalid", status_code=404) 15 | -------------------------------------------------------------------------------- /backend-py/tests/api/organizations/test_get.py: -------------------------------------------------------------------------------- 1 | from . import OrganizationsApiTestBase 2 | 3 | 4 | class OrganizationsApiGetTest(OrganizationsApiTestBase): 5 | def test_get_not_found(self): 6 | self.get_error_response(organization_slug="invalid", status_code=404) 7 | 8 | def test_get(self): 9 | organization = self.create_organization(name="unique-name") 10 | 11 | response = self.get_success_response(organization_slug=organization.slug) 12 | assert response.json["name"] == organization.name 13 | -------------------------------------------------------------------------------- /backend-py/tests/api/organizations/test_index.py: -------------------------------------------------------------------------------- 1 | from . import OrganizationsApiTestBase 2 | 3 | 4 | class OrganizationsApiIndexTest(OrganizationsApiTestBase): 5 | def test_index(self): 6 | self.create_organization("other") 7 | 8 | response = self.get_success_response() 9 | assert len(response.json) == 2 10 | -------------------------------------------------------------------------------- /backend-py/tests/api/organizations/test_post.py: -------------------------------------------------------------------------------- 1 | from src.models import Organization 2 | 3 | from . import OrganizationsApiTestBase 4 | 5 | 6 | class OrganizationsApiPostTest(OrganizationsApiTestBase): 7 | method = "post" 8 | 9 | def test_post(self): 10 | new_name = "New Title" 11 | new_data = { 12 | "name": new_name, 13 | "slug": "new-slug", 14 | "externalSlug": "external-slug", 15 | } 16 | 17 | response = self.get_success_response(data=new_data) 18 | assert response.json["name"] == new_name 19 | 20 | organization_id = response.json["id"] 21 | assert Organization.query.filter(Organization.id == organization_id).first() 22 | 23 | def test_post_invalid(self): 24 | new_data = {"slug": self.organization.slug} 25 | 26 | self.get_error_response(data=new_data, status_code=400) 27 | -------------------------------------------------------------------------------- /backend-py/tests/api/organizations/test_put.py: -------------------------------------------------------------------------------- 1 | from src.models import Organization 2 | 3 | from . import OrganizationsApiTestBase 4 | 5 | 6 | class OrganizationsApiPutTest(OrganizationsApiTestBase): 7 | method = "put" 8 | 9 | def test_put(self): 10 | external_slug = "New Slug" 11 | new_data = {"externalSlug": external_slug} 12 | self.get_success_response( 13 | organization_slug=self.organization.slug, data=new_data 14 | ) 15 | 16 | organization = Organization.query.filter( 17 | Organization.id == self.organization.id 18 | ).first() 19 | assert organization.external_slug == external_slug 20 | 21 | def test_put_empty(self): 22 | self.get_error_response( 23 | organization_slug=self.organization.slug, status_code=400 24 | ) 25 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/integration-platform-example/10cf516b6bb68f5055e6c0f6ce84c07530a8c267/backend-py/tests/api/sentry/__init__.py -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/integration-platform-example/10cf516b6bb68f5055e6c0f6ce84c07530a8c267/backend-py/tests/api/sentry/handlers/__init__.py -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/handlers/test_alert_handler.py: -------------------------------------------------------------------------------- 1 | from src.models import Item 2 | from src.database import db_session 3 | 4 | from tests.api import APITestCase 5 | from tests.mocks import ALERT_RULE_ACTION_VALUES, MOCK_WEBHOOK, ISSUE, METRIC_ALERT 6 | 7 | 8 | class AlertHandlerWebhookTest(APITestCase): 9 | endpoint = "webhook_index" 10 | method = "post" 11 | 12 | def setUp(self): 13 | super().setUp() 14 | self.organization = self.create_organization() 15 | self.sentry_installation = self.create_sentry_installation(self.organization) 16 | self.user = self.create_user(self.organization) 17 | self.user.id = ALERT_RULE_ACTION_VALUES["userId"] 18 | db_session.commit() 19 | 20 | def test_event_alert_triggered(self): 21 | self.get_success_response( 22 | data=MOCK_WEBHOOK["event_alert.triggered"], 23 | headers={"sentry-hook-resource": "event_alert"}, 24 | status_code=202, 25 | ) 26 | item = Item.query.filter(Item.sentry_id == ISSUE["id"]).first() 27 | assert item 28 | assert "Issue Alert" in item.title 29 | 30 | def test_metric_alert_triggered(self): 31 | for action in ["resolved", "warning", "critical"]: 32 | self.get_success_response( 33 | data=MOCK_WEBHOOK[f"metric_alert.{action}"], 34 | headers={"sentry-hook-resource": "metric_alert"}, 35 | status_code=202, 36 | ) 37 | items = Item.query.filter(Item.sentry_alert_id == METRIC_ALERT["id"]) 38 | assert items.count() == 1 39 | item = items.first() 40 | assert item 41 | assert action in item.title.lower() 42 | 43 | def test_event_alert_with_alert_rule_actions(self): 44 | self.get_success_response( 45 | data=MOCK_WEBHOOK["event_alert.triggered:with_alert_rule_action"], 46 | headers={"sentry-hook-resource": "event_alert"}, 47 | status_code=202, 48 | ) 49 | item = Item.query.filter(Item.sentry_id == ISSUE["id"]).first() 50 | assert item 51 | assert "Issue Alert" in item.title 52 | assert ALERT_RULE_ACTION_VALUES["title"] in item.title 53 | assert ALERT_RULE_ACTION_VALUES["description"] in item.description 54 | assert ALERT_RULE_ACTION_VALUES["userId"] == item.assignee_id 55 | 56 | def test_metric_alert_with_alert_rule_actions(self): 57 | for action in ["warning", "critical"]: 58 | self.get_success_response( 59 | data=MOCK_WEBHOOK[f"metric_alert.{action}:with_alert_rule_action"], 60 | headers={"sentry-hook-resource": "metric_alert"}, 61 | status_code=202, 62 | ) 63 | items = Item.query.filter(Item.sentry_alert_id == METRIC_ALERT["id"]) 64 | assert items.count() == 1 65 | item = items.first() 66 | assert item 67 | assert action in item.title.lower() 68 | assert ALERT_RULE_ACTION_VALUES["title"] in item.title 69 | assert ALERT_RULE_ACTION_VALUES["description"] in item.description 70 | assert ALERT_RULE_ACTION_VALUES["userId"] == item.assignee_id 71 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/handlers/test_comment_handler.py: -------------------------------------------------------------------------------- 1 | import copy 2 | 3 | from src.database import db_session 4 | from src.types import ItemComment, JSONData 5 | 6 | from tests.api import APITestCase 7 | from tests.mocks import MOCK_WEBHOOK, ISSUE 8 | 9 | 10 | class CommentHandlerWebhookTest(APITestCase): 11 | endpoint = "webhook_index" 12 | method = "post" 13 | 14 | def setUp(self): 15 | super().setUp() 16 | self.organization = self.create_organization() 17 | self.sentry_installation = self.create_sentry_installation(self.organization) 18 | self.item = self.create_item(self.organization, sentry_id=ISSUE["id"]) 19 | 20 | def assert_comment_from_payload(self, comment: ItemComment, payload: JSONData): 21 | assert comment["text"] == payload["data"]["comment"] 22 | assert comment["author"] == payload["actor"]["name"] 23 | assert comment["timestamp"] == payload["data"]["timestamp"] 24 | assert comment["sentryCommentId"] == payload["data"]["comment_id"] 25 | 26 | def test_unlinked_issue_comment(self): 27 | payload = copy.deepcopy(MOCK_WEBHOOK["comment.created"]) 28 | payload["data"]["issue_id"] = "90210" 29 | self.get_success_response( 30 | data=payload, headers={"sentry-hook-resource": "comment"}, status_code=200 31 | ) 32 | 33 | def test_comment_created(self): 34 | payload = MOCK_WEBHOOK["comment.created"] 35 | self.get_success_response( 36 | data=payload, headers={"sentry-hook-resource": "comment"}, status_code=201 37 | ) 38 | assert len(self.item.comments) == 1 39 | new_comment = self.item.comments[0] 40 | assert new_comment["text"] == payload["data"]["comment"] 41 | assert new_comment["author"] == payload["actor"]["name"] 42 | assert new_comment["timestamp"] == payload["data"]["timestamp"] 43 | assert new_comment["sentryCommentId"] == payload["data"]["comment_id"] 44 | 45 | def test_comment_updated(self): 46 | payload = MOCK_WEBHOOK["comment.updated"] 47 | self.item.comments = [ 48 | { 49 | "text": "old comment", 50 | "author": payload["actor"]["name"], 51 | "timestamp": payload["data"]["timestamp"], 52 | "sentryCommentId": payload["data"]["comment_id"], 53 | } 54 | ] 55 | db_session.commit() 56 | assert len(self.item.comments) == 1 57 | 58 | self.get_success_response( 59 | data=payload, headers={"sentry-hook-resource": "comment"}, status_code=200 60 | ) 61 | assert len(self.item.comments) == 1 62 | existing_comment = self.item.comments[0] 63 | assert existing_comment["text"] == payload["data"]["comment"] 64 | 65 | def test_comment_deleted(self): 66 | payload = MOCK_WEBHOOK["comment.deleted"] 67 | self.item.comments = [ 68 | { 69 | "sentryCommentId": payload["data"]["comment_id"], 70 | "timestamp": payload["data"]["timestamp"], 71 | "author": payload["actor"]["name"], 72 | "text": payload["data"]["comment"], 73 | }, 74 | { 75 | "sentryCommentId": "90210", 76 | "timestamp": payload["data"]["timestamp"], 77 | "author": payload["actor"]["name"], 78 | "text": "untouched comment", 79 | }, 80 | ] 81 | db_session.commit() 82 | assert len(self.item.comments) == 2 83 | 84 | self.get_success_response( 85 | data=payload, headers={"sentry-hook-resource": "comment"}, status_code=204 86 | ) 87 | assert len(self.item.comments) == 1 88 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/handlers/test_issue_handler.py: -------------------------------------------------------------------------------- 1 | from src.models import Item, User 2 | from src.types import ItemColumn 3 | 4 | from tests.api import APITestCase 5 | from tests.mocks import MOCK_WEBHOOK, ISSUE 6 | 7 | 8 | class IssueHandlerWebhookTest(APITestCase): 9 | endpoint = "webhook_index" 10 | method = "post" 11 | 12 | def setUp(self): 13 | super().setUp() 14 | self.organization = self.create_organization() 15 | self.sentry_installation = self.create_sentry_installation(self.organization) 16 | 17 | def test_issue_assigned(self): 18 | self.get_success_response( 19 | data=MOCK_WEBHOOK["issue.assigned"], 20 | headers={"sentry-hook-resource": "issue"}, 21 | status_code=202, 22 | ) 23 | item = Item.query.filter(Item.sentry_id == ISSUE["id"]).first() 24 | assert item 25 | user = User.query.filter(User.username == ISSUE["assignedTo"]["email"]).first() 26 | assert user 27 | assert item.assignee_id == user.id 28 | assert item.column.value == ItemColumn.Doing.value 29 | 30 | def test_issue_created(self): 31 | self.get_success_response( 32 | data=MOCK_WEBHOOK["issue.created"], 33 | headers={"sentry-hook-resource": "issue"}, 34 | status_code=201, 35 | ) 36 | item = Item.query.filter(Item.sentry_id == ISSUE["id"]).first() 37 | assert item 38 | assert item.title == ISSUE["title"] 39 | assert item.description == f"{ISSUE['shortId']} - {ISSUE['culprit']}" 40 | assert item.is_ignored is False 41 | assert item.column.value == ItemColumn.Todo.value 42 | assert item.sentry_id == ISSUE["id"] 43 | 44 | assert item.organization_id == self.sentry_installation.organization_id 45 | 46 | def test_issue_ignored(self): 47 | self.get_success_response( 48 | data=MOCK_WEBHOOK["issue.ignored"], 49 | headers={"sentry-hook-resource": "issue"}, 50 | status_code=202, 51 | ) 52 | item = Item.query.filter(Item.sentry_id == ISSUE["id"]).first() 53 | assert item 54 | assert item.is_ignored is True 55 | 56 | def test_issue_resolved(self): 57 | self.get_success_response( 58 | data=MOCK_WEBHOOK["issue.resolved"], 59 | headers={"sentry-hook-resource": "issue"}, 60 | status_code=202, 61 | ) 62 | item = Item.query.filter(Item.sentry_id == ISSUE["id"]).first() 63 | assert item 64 | assert item.column.value == ItemColumn.Done.value 65 | 66 | def test_issue_unknown_action(self): 67 | self.get_error_response( 68 | data={**MOCK_WEBHOOK["issue.assigned"], "action": "bookmarked"}, 69 | headers={"sentry-hook-resource": "issue"}, 70 | status_code=400, 71 | ) 72 | item = Item.query.filter(Item.sentry_id == ISSUE["id"]).first() 73 | assert item is None 74 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/test_alert_rule_action.py: -------------------------------------------------------------------------------- 1 | from src.database import db_session 2 | 3 | from tests.api import APITestCase 4 | from tests.mocks import MOCK_ALERT_RULE_ACTION, ALERT_RULE_ACTION_VALUES 5 | 6 | 7 | class AlertRuleActionTest(APITestCase): 8 | endpoint = "alert_rule_action" 9 | method = "post" 10 | 11 | def setUp(self): 12 | super().setUp() 13 | 14 | def test_handles_successful_request(self): 15 | organization = self.create_organization() 16 | self.create_sentry_installation(organization) 17 | user = self.create_user(organization) 18 | setattr(user, "id", ALERT_RULE_ACTION_VALUES["userId"]) 19 | db_session.commit() 20 | self.get_success_response(data=MOCK_ALERT_RULE_ACTION, status_code=200) 21 | 22 | def test_surfaces_errors(self): 23 | response = self.get_error_response(data=MOCK_ALERT_RULE_ACTION, status_code=400) 24 | assert response.json["message"] == "Invalid installation was provided" 25 | 26 | organization = self.create_organization() 27 | self.create_sentry_installation(organization) 28 | response = self.get_error_response( 29 | data={**MOCK_ALERT_RULE_ACTION, "fields": []} 30 | ) 31 | assert response.json["message"] == "Title and description are required" 32 | 33 | response = self.get_error_response(data=MOCK_ALERT_RULE_ACTION) 34 | assert response.json["message"] == "Selected user was not found" 35 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/test_issue_link.py: -------------------------------------------------------------------------------- 1 | from src.models import Item 2 | from src.database import db_session 3 | 4 | from tests.api import APITestCase 5 | from tests.mocks import MOCK_ISSUE_LINK 6 | 7 | 8 | class CreateIssueLinkTest(APITestCase): 9 | endpoint = "create_issue_link" 10 | method = "post" 11 | 12 | def setUp(self): 13 | super().setUp() 14 | self.organization = self.create_organization() 15 | self.sentry_installation = self.create_sentry_installation(self.organization) 16 | self.initial_item_count = 0 17 | 18 | def test_create_issue_link(self): 19 | assert Item.query.count() == self.initial_item_count 20 | # Check that the response was appropriate 21 | response = self.get_success_response(data=MOCK_ISSUE_LINK) 22 | assert response.json.get("webUrl") is not None 23 | assert response.json.get("project") == "ACME" 24 | # Check that item was created properly 25 | new_issue_id = response.json.get("identifier") 26 | assert Item.query.count() == self.initial_item_count + 1 27 | item = Item.query.filter(Item.id == new_issue_id).first() 28 | for field in ["title", "description", "complexity"]: 29 | assert getattr(item, field) == MOCK_ISSUE_LINK["fields"].get(field) 30 | assert item.column.value == MOCK_ISSUE_LINK["fields"]["column"] 31 | assert item.sentry_id == MOCK_ISSUE_LINK["issueId"] 32 | 33 | 34 | class LinkIssueLinkTest(APITestCase): 35 | endpoint = "link_issue_link" 36 | method = "post" 37 | 38 | def setUp(self): 39 | super().setUp() 40 | self.organization = self.create_organization() 41 | self.sentry_installation = self.create_sentry_installation(self.organization) 42 | self.item = self.create_item(self.organization) 43 | db_session.commit() 44 | payload = MOCK_ISSUE_LINK 45 | payload["fields"]["itemId"] = self.item.id 46 | self.payload = payload 47 | self.initial_item_count = 1 48 | 49 | def test_link_issue_link(self): 50 | assert Item.query.count() == self.initial_item_count 51 | assert self.item.sentry_id is None 52 | # Check that the existing item was updated 53 | response = self.get_success_response(data=self.payload) 54 | assert Item.query.count() == self.initial_item_count 55 | assert self.item.sentry_id == self.payload["issueId"] 56 | # Check that the response was appropriate 57 | assert response.json.get("webUrl") is not None 58 | assert response.json.get("project") == "ACME" 59 | assert response.json.get("identifier") == str(self.item.id) 60 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/test_options.py: -------------------------------------------------------------------------------- 1 | from tests.api import APITestCase 2 | 3 | 4 | class SentryItemOptionsTest(APITestCase): 5 | endpoint = "get_item_options" 6 | method = "get" 7 | 8 | def setUp(self): 9 | super().setUp() 10 | self.organization = self.create_organization() 11 | self.sentry_installation = self.create_sentry_installation(self.organization) 12 | self.item = self.create_item(self.organization) 13 | self.initial_item_count = 1 14 | 15 | def test_sentry_item_options(self): 16 | response = self.get_success_response( 17 | installationId=self.sentry_installation.uuid 18 | ) 19 | assert len(response.json) == self.initial_item_count 20 | # Check that the options are all valid 21 | for option in response.json: 22 | assert option.get("value") is not None 23 | assert option.get("label") is not None 24 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/test_setup.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import responses 4 | 5 | from dotenv import load_dotenv 6 | from tests.api import APITestCase 7 | from tests.mocks import MOCK_SETUP 8 | 9 | load_dotenv() 10 | SENTRY_URL = os.getenv("SENTRY_URL") 11 | 12 | 13 | class SetupTest(APITestCase): 14 | endpoint = "setup_index" 15 | method = "post" 16 | 17 | def setUp(self): 18 | super().setUp() 19 | self.organization = self.create_organization() 20 | 21 | @responses.activate 22 | def test_post(self): 23 | uuid = MOCK_SETUP["postInstall"]["installationId"] 24 | 25 | # Simulate getting a token. 26 | responses.add( 27 | responses.POST, 28 | f"{SENTRY_URL}/api/0/sentry-app-installations/{uuid}/authorizations/", 29 | body=json.dumps(MOCK_SETUP["newToken"]), 30 | ) 31 | 32 | # Simulate updating an installation. 33 | responses.add( 34 | responses.PUT, 35 | f"{SENTRY_URL}/api/0/sentry-app-installations/{uuid}/", 36 | body=json.dumps(MOCK_SETUP["installation"]), 37 | ) 38 | 39 | response = self.get_success_response( 40 | data={**MOCK_SETUP["postInstall"], "organizationId": self.organization.id}, 41 | status_code=201, 42 | ) 43 | redirect_url = response.json.get("redirectUrl") 44 | 45 | sentry_org_slug = MOCK_SETUP["postInstall"]["sentryOrgSlug"] 46 | app_slug = MOCK_SETUP["installation"]["app"]["slug"] 47 | assert ( 48 | redirect_url 49 | == f"{SENTRY_URL}/settings/{sentry_org_slug}/sentry-apps/{app_slug}/" 50 | ) 51 | -------------------------------------------------------------------------------- /backend-py/tests/api/sentry/test_webhook.py: -------------------------------------------------------------------------------- 1 | from src.models import SentryInstallation 2 | 3 | from tests.api import APITestCase 4 | from tests.mocks import MOCK_WEBHOOK 5 | 6 | 7 | class WebhookTest(APITestCase): 8 | endpoint = "webhook_index" 9 | method = "post" 10 | 11 | def setUp(self): 12 | super().setUp() 13 | self.organization = self.create_organization() 14 | self.sentry_installation = self.create_sentry_installation(self.organization) 15 | 16 | def test_bad_requests(self): 17 | self.get_error_response(status_code=400) 18 | self.get_error_response(data={"malformed": True}, status_code=400) 19 | self.get_error_response( 20 | data=MOCK_WEBHOOK["installation.deleted"], 21 | headers={"missing": "header"}, 22 | status_code=400, 23 | ) 24 | unknown_installation_webhook = { 25 | **MOCK_WEBHOOK["installation.deleted"], 26 | "installation": {"uuid": "unknown"}, 27 | } 28 | self.get_error_response( 29 | data=unknown_installation_webhook, 30 | headers={"sentry-hook-resource": "installation"}, 31 | status_code=404, 32 | ) 33 | 34 | def test_installation_deleted(self): 35 | assert SentryInstallation.query.count() == 1 36 | self.get_success_response( 37 | data=MOCK_WEBHOOK["installation.deleted"], 38 | headers={"sentry-hook-resource": "installation"}, 39 | ) 40 | assert SentryInstallation.query.count() == 0 41 | -------------------------------------------------------------------------------- /backend-py/tests/api/test_server.py: -------------------------------------------------------------------------------- 1 | from tests.api import APITestCase 2 | 3 | 4 | class ServerTest(APITestCase): 5 | def test_not_found(self): 6 | response = self.client.get("/") 7 | assert response.status_code == 404 8 | -------------------------------------------------------------------------------- /backend-py/tests/api/users/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import UsersApiTestBase 2 | 3 | __all__ = ("UsersApiTestBase",) 4 | -------------------------------------------------------------------------------- /backend-py/tests/api/users/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from tests.api import APITestCase 4 | 5 | 6 | class UsersApiTestBase(APITestCase): 7 | endpoint = "user_api" 8 | 9 | def setUp(self): 10 | super().setUp() 11 | 12 | self.organization = self.create_organization() 13 | self.user = self.create_user(self.organization) 14 | -------------------------------------------------------------------------------- /backend-py/tests/api/users/test_delete.py: -------------------------------------------------------------------------------- 1 | from . import UsersApiTestBase 2 | 3 | from src.models import User 4 | 5 | 6 | class UsersApiDeleteTest(UsersApiTestBase): 7 | method = "delete" 8 | 9 | def test_delete(self): 10 | self.get_success_response(user_id=self.user.id) 11 | assert User.query.count() == 0 12 | 13 | def test_delete_not_found(self): 14 | self.get_error_response(user_id=0, status_code=404) 15 | -------------------------------------------------------------------------------- /backend-py/tests/api/users/test_get.py: -------------------------------------------------------------------------------- 1 | from . import UsersApiTestBase 2 | 3 | 4 | class UsersApiGetTest(UsersApiTestBase): 5 | def test_get_not_found(self): 6 | self.get_error_response(user_id=0, status_code=404) 7 | 8 | def test_get(self): 9 | user = self.create_user(self.organization, name="Unique Name") 10 | 11 | response = self.get_success_response(user_id=user.id) 12 | assert response.json["avatar"] == user.avatar 13 | -------------------------------------------------------------------------------- /backend-py/tests/api/users/test_index.py: -------------------------------------------------------------------------------- 1 | from . import UsersApiTestBase 2 | 3 | 4 | class UsersApiIndexTest(UsersApiTestBase): 5 | def setUp(self): 6 | super().setUp() 7 | 8 | other_organization = self.create_organization("other") 9 | self.create_user(other_organization, "other user") 10 | 11 | def test_index(self): 12 | response = self.get_success_response() 13 | assert len(response.json) == 2 14 | 15 | def test_index_organization(self): 16 | response = self.get_success_response(organization=self.organization.slug) 17 | assert len(response.json) == 1 18 | assert response.json[0]["organizationId"] == self.organization.id 19 | -------------------------------------------------------------------------------- /backend-py/tests/api/users/test_post.py: -------------------------------------------------------------------------------- 1 | from src.models import User 2 | 3 | from . import UsersApiTestBase 4 | 5 | 6 | class UsersApiPostTest(UsersApiTestBase): 7 | method = "post" 8 | 9 | def test_post(self): 10 | new_name = "New Name" 11 | new_data = {"name": new_name, "organizationId": self.organization.id} 12 | 13 | response = self.get_success_response(data=new_data) 14 | assert response.json["name"] == new_name 15 | 16 | user_id = response.json["id"] 17 | assert User.query.filter(User.id == user_id).first() 18 | 19 | def test_post_invalid(self): 20 | new_data = {"organizationId": "invalid"} 21 | 22 | self.get_error_response(data=new_data, status_code=400) 23 | -------------------------------------------------------------------------------- /backend-py/tests/api/users/test_put.py: -------------------------------------------------------------------------------- 1 | from src.models import User 2 | 3 | from . import UsersApiTestBase 4 | 5 | 6 | class UsersApiPutTest(UsersApiTestBase): 7 | method = "put" 8 | 9 | def test_put(self): 10 | new_name = "New Name" 11 | new_data = {"name": new_name} 12 | self.get_success_response(user_id=self.user.id, data=new_data) 13 | 14 | user = User.query.filter(User.id == self.user.id).first() 15 | assert user.name == new_name 16 | 17 | def test_put_empty(self): 18 | self.get_error_response(user_id=self.user.id, status_code=400) 19 | 20 | def test_put_invalid(self): 21 | invalid_data = dict(assignee_id="invalid") 22 | self.get_error_response( 23 | user_id=self.user.id, data=invalid_data, status_code=400 24 | ) 25 | -------------------------------------------------------------------------------- /backend-py/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ["FLASK_ENV"] = "test" 4 | -------------------------------------------------------------------------------- /backend-py/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from datetime import datetime 3 | 4 | import re 5 | 6 | from src.models import Item, Organization, User, SentryInstallation 7 | from src.types import ItemColumn 8 | from .mocks import INSTALLATION, MOCK_SETUP 9 | 10 | 11 | def create_user( 12 | db_session, 13 | organization: Organization, 14 | name: str = "Test User", 15 | ) -> Organization: 16 | user = User( 17 | name=name, 18 | username=name.lower().replace(" ", ""), 19 | organization_id=organization.id, 20 | ) 21 | db_session.add(user) 22 | db_session.commit() 23 | 24 | return user 25 | 26 | 27 | def create_organization( 28 | db_session, 29 | name: str = "Organization", 30 | ) -> Organization: 31 | slug = re.sub(r"\s+", "-", name.lower().strip()) 32 | 33 | organization = Organization( 34 | name=name, 35 | slug=slug, 36 | external_slug=slug, 37 | ) 38 | db_session.add(organization) 39 | db_session.commit() 40 | 41 | return organization 42 | 43 | 44 | def create_item( 45 | db_session, 46 | organization: Organization, 47 | user: User | None = None, 48 | title: str = "Item Title", 49 | **item_kwargs, 50 | ) -> Item: 51 | item = Item( 52 | title=title, 53 | description="computers", 54 | complexity=1, 55 | column=ItemColumn.Todo, 56 | assignee_id=getattr(user, "id", None), 57 | organization_id=organization.id, 58 | **item_kwargs, 59 | ) 60 | 61 | db_session.add(item) 62 | db_session.commit() 63 | 64 | return item 65 | 66 | 67 | def create_sentry_installation( 68 | db_session, 69 | organization: Organization, 70 | ) -> SentryInstallation: 71 | sentry_installation = SentryInstallation( 72 | uuid=INSTALLATION["uuid"], 73 | org_slug=INSTALLATION["organization"]["slug"], 74 | token=MOCK_SETUP["newToken"]["token"], 75 | refresh_token=MOCK_SETUP["newToken"]["refreshToken"], 76 | expires_at=datetime.strptime( 77 | MOCK_SETUP["newToken"]["expiresAt"], "%Y-%m-%dT%H:%M:%S.%fZ" 78 | ), 79 | organization_id=organization.id, 80 | ) 81 | 82 | db_session.add(sentry_installation) 83 | db_session.commit() 84 | 85 | return sentry_installation 86 | -------------------------------------------------------------------------------- /backend-ts/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:import/recommended', 9 | 'plugin:import/typescript', 10 | 'plugin:@typescript-eslint/recommended', 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaVersion: 'latest', 15 | sourceType: 'module', 16 | }, 17 | plugins: ['@typescript-eslint', 'prettier', 'simple-import-sort'], 18 | rules: { 19 | 'prettier/prettier': 'error', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | 'simple-import-sort/imports': 'error', 22 | 'simple-import-sort/exports': 'error', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /backend-ts/.prettierrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | bracketSpacing: false, 4 | printWidth: 90, 5 | semi: true, 6 | singleQuote: true, 7 | tabWidth: 2, 8 | trailingComma: 'es5', 9 | useTabs: false, 10 | arrowParens: 'avoid', 11 | }; 12 | -------------------------------------------------------------------------------- /backend-ts/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.13 as backend-ts 2 | # Installing packages... 3 | WORKDIR /backend-ts 4 | COPY package.json . 5 | COPY package-lock.json . 6 | RUN npm install 7 | # Copy source files after installing packages 8 | COPY . . 9 | # Preparing startup 10 | CMD ["npm", "start"] 11 | -------------------------------------------------------------------------------- /backend-ts/README.md: -------------------------------------------------------------------------------- 1 | # Backend - TypeScript 2 | 3 | This directory contains the backend code written in TypeScript (with Express and Sequelize). 4 | 5 | ## Development 6 | 7 | To start, you'll need to install [Docker](https://docs.docker.com/engine/install/) and ensure it is running. 8 | 9 | Then, to spin up this service: 10 | 11 | ```bash 12 | docker compose up backend-ts 13 | ``` 14 | 15 | If adding dependencies or changing the environment variables, be sure to rebuild the image. We suggest using [Volta](https://volta.sh/) to manage your node version when installing packages. 16 | 17 | ```bash 18 | npm install my-package 19 | docker compose build backend-ts 20 | ``` 21 | 22 | ## Testing 23 | 24 | To check for linting errors, run the following in this directory: 25 | 26 | ```bash 27 | npm run lint 28 | ``` 29 | 30 | To run all tests, run the following commands: 31 | 32 | ```bash 33 | make setup-tests 34 | cd backend-ts 35 | npm run test 36 | ``` 37 | -------------------------------------------------------------------------------- /backend-ts/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | ['@babel/preset-typescript', {allowDeclareFields: true}], 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /backend-ts/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; 6 | -------------------------------------------------------------------------------- /backend-ts/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["**/*.test.ts", "**/*.spec.ts", "node_modules"], 3 | "watch": ["src"], 4 | "exec": "ts-node src", 5 | "ext": "ts" 6 | } 7 | -------------------------------------------------------------------------------- /backend-ts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integration-platform-backend", 3 | "version": "0.0.0", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/getsentry/integration-platform-example.git" 8 | }, 9 | "scripts": { 10 | "start": "nodemon", 11 | "lint": "eslint . --ext .ts && prettier -c src", 12 | "lint:ci": "npm run lint", 13 | "lint:dev": "nodemon -e 'ts' -x 'npm run lint'", 14 | "lint:fix": "eslint . --ext .ts --fix && prettier -c src --write", 15 | "test": "NODE_ENV=test jest -i .spec.ts", 16 | "test:ci": "npm run test", 17 | "test:dev": "npm run test -- --watch", 18 | "seed": "ts-node src/bin/seeder" 19 | }, 20 | "dependencies": { 21 | "axios": "^0.26.0", 22 | "cors": "^2.8.5", 23 | "express": "^4.17.3", 24 | "pg": "^8.7.3", 25 | "pg-hstore": "^2.3.4", 26 | "reflect-metadata": "^0.1.13", 27 | "sequelize": "^6.17.0", 28 | "sequelize-typescript": "^2.1.3" 29 | }, 30 | "devDependencies": { 31 | "@babel/core": "^7.17.5", 32 | "@babel/preset-env": "^7.16.11", 33 | "@babel/preset-typescript": "^7.16.7", 34 | "@types/cors": "^2.8.12", 35 | "@types/express": "^4.17.13", 36 | "@types/jest": "^27.4.1", 37 | "@types/node": "^17.0.21", 38 | "@types/supertest": "^2.0.11", 39 | "@types/validator": "^13.7.1", 40 | "@typescript-eslint/eslint-plugin": "^5.12.1", 41 | "@typescript-eslint/parser": "^5.12.1", 42 | "dotenv": "^16.0.0", 43 | "eslint": "^7.32.0", 44 | "eslint-plugin-import": "^2.25.4", 45 | "eslint-plugin-prettier": "^4.0.0", 46 | "eslint-plugin-simple-import-sort": "^7.0.0", 47 | "jest": "^27.5.1", 48 | "mocha": "^9.2.1", 49 | "nodemon": "^2.0.15", 50 | "prettier": "2.5.1", 51 | "supertest": "^6.2.2", 52 | "ts-jest": "^27.1.3", 53 | "ts-node": "^10.5.0", 54 | "typescript": "^4.5.5" 55 | }, 56 | "volta": { 57 | "node": "16.13.1" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend-ts/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import itemRoutes from './items'; 4 | import organizationRoutes from './organizations'; 5 | import sentryRoutes from './sentry'; 6 | import userRoutes from './users'; 7 | 8 | const router = express.Router(); 9 | 10 | router.use('/sentry', sentryRoutes); 11 | router.use('/organizations', organizationRoutes); 12 | router.use('/items', itemRoutes); 13 | router.use('/users', userRoutes); 14 | 15 | export default router; 16 | -------------------------------------------------------------------------------- /backend-ts/src/api/items.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Item from '../models/Item.model'; 4 | import Organization from '../models/Organization.model'; 5 | import SentryAPIClient from '../util/SentryAPIClient'; 6 | 7 | const router = express.Router(); 8 | 9 | async function addSentryAPIData( 10 | organization: Organization & {items: Item[]} 11 | ): Promise { 12 | // Create an APIClient to talk to Sentry 13 | const sentry = await SentryAPIClient.create(organization); 14 | return Promise.all( 15 | organization.items.map(async item => { 16 | if (item.sentryId) { 17 | // Use the numerical ID to fetch the short ID 18 | const sentryData = await sentry.get(`/organizations/${organization.externalSlug}/issues/${item.sentryId}/`); 19 | // Replace the numerical ID with the short ID 20 | const shortId = (sentryData || {})?.data?.shortId; 21 | if (shortId) { 22 | item.sentryId = shortId; 23 | } 24 | return item; 25 | } 26 | return item; 27 | }) 28 | ); 29 | } 30 | 31 | router.get('/', async (request, response) => { 32 | const {organization: slug} = request.query; 33 | if (slug) { 34 | const organization = await Organization.findOne({ 35 | include: Item, 36 | where: {slug}, 37 | }); 38 | if (organization) { 39 | return response.send( 40 | organization.items.some(item => !!item.sentryId) 41 | ? await addSentryAPIData(organization) 42 | : organization.items 43 | ); 44 | } 45 | } 46 | const items = await Item.findAll(); 47 | return response.send(items); 48 | }); 49 | 50 | router.post('/', async (request, response) => { 51 | const {title, description, complexity, column, assigneeId, organizationId} = 52 | request.body; 53 | const item = await Item.create({ 54 | title, 55 | description, 56 | complexity, 57 | column, 58 | assigneeId, 59 | organizationId, 60 | }); 61 | return response.status(201).send(item); 62 | }); 63 | 64 | router.put('/:itemId', async (request, response) => { 65 | const {title, description, complexity, column, assigneeId, organizationId} = 66 | request.body; 67 | const item = await Item.update( 68 | { 69 | title, 70 | description, 71 | complexity, 72 | column, 73 | assigneeId, 74 | organizationId, 75 | }, 76 | {where: {id: request.params.itemId}} 77 | ); 78 | return response.send(item); 79 | }); 80 | 81 | router.delete('/:itemId', async (request, response) => { 82 | await Item.destroy({where: {id: request.params.itemId}}); 83 | return response.sendStatus(204); 84 | }); 85 | 86 | export default router; 87 | -------------------------------------------------------------------------------- /backend-ts/src/api/middleware/index.ts: -------------------------------------------------------------------------------- 1 | import verifySentrySignature from './verifySentrySignature'; 2 | 3 | export {verifySentrySignature}; 4 | -------------------------------------------------------------------------------- /backend-ts/src/api/middleware/verifySentrySignature.ts: -------------------------------------------------------------------------------- 1 | import {createHmac} from 'crypto'; 2 | import {NextFunction, Request, Response} from 'express'; 3 | 4 | // There are few hacks in this verification step (denoted with HACK) that we at Sentry hope 5 | // to migrate away from in the future. Presently however, for legacy reasons, they are 6 | // necessary to keep around, so we've shown how to deal with them here. 7 | 8 | function getSignatureBody(req: Request): string { 9 | const stringifiedBody = JSON.stringify(req.body); 10 | // HACK: This is necessary since express.json() converts the empty request body to {} 11 | return stringifiedBody === '{}' ? '' : stringifiedBody; 12 | } 13 | 14 | export default function verifySentrySignature( 15 | request: Request, 16 | response: Response, 17 | next: NextFunction 18 | ) { 19 | /** 20 | * This function will authenticate that the requests are coming from Sentry. 21 | * It allows us to be confident that all the code run after this middleware are 22 | * using verified data sent directly from Sentry. 23 | */ 24 | if (process.env.NODE_ENV == 'test') { 25 | return next(); 26 | } 27 | const hmac = createHmac('sha256', process.env.SENTRY_CLIENT_SECRET); 28 | 29 | hmac.update(getSignatureBody(request), 'utf8'); 30 | const digest = hmac.digest('hex'); 31 | if ( 32 | // HACK: The signature header may be one of these two values 33 | digest === request.headers['sentry-hook-signature'] || 34 | digest === request.headers['sentry-app-signature'] 35 | ) { 36 | console.info('Authorized: Verified request came from Sentry'); 37 | return next(); 38 | } 39 | console.info('Unauthorized: Could not verify request came from Sentry'); 40 | response.sendStatus(401); 41 | } 42 | -------------------------------------------------------------------------------- /backend-ts/src/api/organizations.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Organization from '../models/Organization.model'; 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', async (request, response) => { 8 | const data = await Organization.findAll(); 9 | return response.send(data); 10 | }); 11 | 12 | router.post('/', async (request, response) => { 13 | const {name, slug, externalSlug} = request.body; 14 | const organization = await Organization.create({name, slug, externalSlug}); 15 | return response.status(201).send(organization); 16 | }); 17 | 18 | router.put('/:organizationSlug', async (request, response) => { 19 | const {name, slug, externalSlug} = request.body; 20 | const organization = await Organization.update( 21 | {name, slug, externalSlug}, 22 | {where: {slug: request.params.organizationSlug}} 23 | ); 24 | return response.send(organization); 25 | }); 26 | 27 | router.delete('/:organizationSlug', async (request, response) => { 28 | await Organization.destroy({where: {slug: request.params.organizationSlug}}); 29 | return response.sendStatus(204); 30 | }); 31 | 32 | export default router; 33 | -------------------------------------------------------------------------------- /backend-ts/src/api/sentry/alertRuleAction.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import SentryInstallation from '../../models/SentryInstallation.model'; 4 | import User from '../../models/User.model'; 5 | 6 | const router = express.Router(); 7 | 8 | // The shape of your settings will depend on how you configure your form fields 9 | // This example coordinates with integration-schema.json for 'alert-rule-settings' 10 | export type AlertRuleSettings = { 11 | title?: string; 12 | description?: string; 13 | userId?: string; 14 | }; 15 | 16 | export type SentryField = { 17 | name: string; 18 | value: any; 19 | }; 20 | 21 | export const convertSentryFieldsToDict = (fields: SentryField[]): AlertRuleSettings => 22 | fields.reduce((acc: Record, {name, value}) => { 23 | acc[name] = value; 24 | return acc; 25 | }, {}); 26 | 27 | // This endpoint will only be called if the 'alert-rule-action' is present in the schema. 28 | router.post('/', async (request, response) => { 29 | const {installationId: uuid} = request.body; 30 | const sentryInstallation = uuid 31 | ? await SentryInstallation.findOne({ 32 | where: {uuid}, 33 | }) 34 | : null; 35 | if (!sentryInstallation) { 36 | return response.status(400).send({message: 'Invalid installation was provided'}); 37 | } 38 | 39 | // Now we can validate the data the user provided to our alert rule action 40 | // Sending a payload with the 'message' key will be surfaced to the user in Sentry 41 | // This stops the user from creating the alert, so it's a good way to bubble up relevant info. 42 | const alertRuleActionSettings = convertSentryFieldsToDict(request.body.fields); 43 | if (!alertRuleActionSettings.title || !alertRuleActionSettings.description) { 44 | return response.status(400).send({message: 'Title and description are required'}); 45 | } 46 | if (alertRuleActionSettings.userId) { 47 | const user = await User.findOne({ 48 | where: { 49 | id: alertRuleActionSettings.userId, 50 | organizationId: sentryInstallation.organizationId, 51 | }, 52 | }); 53 | if (!user) { 54 | return response.status(400).send({message: 'Selected user was not found'}); 55 | } 56 | } 57 | 58 | console.info('Successfully validated Sentry alert rule'); 59 | 60 | // By sending a successful response code, we are approving that alert to notify our application. 61 | response.sendStatus(200); 62 | }); 63 | 64 | export default router; 65 | -------------------------------------------------------------------------------- /backend-ts/src/api/sentry/handlers/commentHandler.ts: -------------------------------------------------------------------------------- 1 | import {Response} from 'express'; 2 | 3 | import Item, {ItemComment} from '../../../models/Item.model'; 4 | import SentryInstallation from '../../../models/SentryInstallation.model'; 5 | 6 | async function handleCreated(item: Item, comment: ItemComment) { 7 | await item.update({comments: [...(item.comments ?? []), comment]}); 8 | console.info(`Added new comment from Sentry issue`); 9 | } 10 | 11 | async function handleUpdated(item: Item, comment: ItemComment) { 12 | // Create a copy since Array.prototype.splice mutates the original array 13 | const comments = [...(item.comments ?? [])]; 14 | const commentIndex = comments.findIndex( 15 | c => c.sentryCommentId === comment.sentryCommentId 16 | ); 17 | comments.splice(commentIndex, 1, comment); 18 | await item.update({comments}); 19 | console.info(`Updated comment from Sentry issue`); 20 | } 21 | 22 | async function handleDeleted(item: Item, comment: ItemComment) { 23 | await item.update({ 24 | comments: (item.comments ?? []).filter( 25 | c => c.sentryCommentId !== comment.sentryCommentId 26 | ), 27 | }); 28 | console.info(`Deleted comment from Sentry issue`); 29 | } 30 | 31 | export default async function commentHandler( 32 | response: Response, 33 | action: string, 34 | sentryInstallation: SentryInstallation, 35 | data: Record, 36 | actor: Record 37 | ): Promise { 38 | const item = await Item.findOne({ 39 | where: { 40 | sentryId: `${data.issue_id}`, 41 | organizationId: sentryInstallation.organizationId, 42 | }, 43 | }); 44 | if (!item) { 45 | console.info(`Ignoring comment for unlinked Sentry issue`); 46 | response.status(200); 47 | return; 48 | } 49 | // In your application you may want to map Sentry user IDs (actor.id) to your internal user IDs 50 | // for a richer comment sync experience 51 | const incomingComment: ItemComment = { 52 | text: data.comment, 53 | author: actor.name, 54 | timestamp: data.timestamp, 55 | sentryCommentId: data.comment_id, 56 | }; 57 | 58 | switch (action) { 59 | case 'created': 60 | await handleCreated(item, incomingComment); 61 | response.status(201); 62 | break; 63 | case 'updated': 64 | await handleUpdated(item, incomingComment); 65 | response.status(200); 66 | break; 67 | case 'deleted': 68 | await handleDeleted(item, incomingComment); 69 | response.status(204); 70 | break; 71 | default: 72 | console.info(`Unexpected Sentry comment action: ${action}`); 73 | response.status(400); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /backend-ts/src/api/sentry/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import {verifySentrySignature} from '../middleware'; 4 | import alertRuleActionRoutes from './alertRuleAction'; 5 | import issueLinkRoutes from './issueLink'; 6 | import optionRoutes from './options'; 7 | import setupRoutes from './setup'; 8 | import webhookRoutes from './webhook'; 9 | 10 | const router = express.Router(); 11 | 12 | router.use('/setup', setupRoutes); 13 | // We need to verify that the request came from Sentry before we can... 14 | // ...trust the webhook data. 15 | router.use('/webhook', verifySentrySignature, webhookRoutes); 16 | // ...allow queries to the options fields. 17 | router.use('/options', verifySentrySignature, optionRoutes); 18 | // ...allow links to be created between Sentry issues and our items. 19 | router.use('/issue-link', verifySentrySignature, issueLinkRoutes); 20 | // ...trigger behavior in our app when alerts have been fired in Sentry. 21 | router.use('/alert-rule-action', verifySentrySignature, alertRuleActionRoutes); 22 | 23 | export default router; 24 | -------------------------------------------------------------------------------- /backend-ts/src/api/sentry/options.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Item from '../../models/Item.model'; 4 | import SentryInstallation from '../../models/SentryInstallation.model'; 5 | import User from '../../models/User.model'; 6 | 7 | const router = express.Router(); 8 | 9 | type SentrySelectOption = { 10 | label: string; 11 | value: string; 12 | default?: boolean; 13 | }; 14 | 15 | // These endpoints are used to populate the options for 'Select' FormFields in Sentry. 16 | 17 | router.get('/items', async (request, response) => { 18 | const {installationId: uuid} = request.query; 19 | const sentryInstallation = await SentryInstallation.findOne({ 20 | where: {uuid}, 21 | }); 22 | if (!sentryInstallation) { 23 | return response.sendStatus(404); 24 | } 25 | // We can use the installation data to filter the items we return to Sentry. 26 | const items = await Item.findAll({ 27 | where: {organizationId: sentryInstallation.organizationId}, 28 | }); 29 | // Sentry requires the results in this exact format. 30 | const result: SentrySelectOption[] = items.map(item => ({ 31 | label: item.title, 32 | value: item.id, 33 | })); 34 | console.info('Populating item options in Sentry'); 35 | return response.send(result); 36 | }); 37 | 38 | router.get('/users', async (request, response) => { 39 | const {installationId: uuid} = request.query; 40 | const sentryInstallation = await SentryInstallation.findOne({ 41 | where: {uuid}, 42 | }); 43 | if (!sentryInstallation) { 44 | return response.sendStatus(404); 45 | } 46 | // We can use the installation data to filter the users we return to Sentry. 47 | const users = await User.findAll({ 48 | where: {organizationId: sentryInstallation.organizationId}, 49 | }); 50 | // Sentry requires the results in this exact format. 51 | const result: SentrySelectOption[] = users.map(user => ({ 52 | label: user.name, 53 | value: user.id, 54 | })); 55 | console.info('Populating user options in Sentry'); 56 | return response.send(result); 57 | }); 58 | 59 | export default router; 60 | -------------------------------------------------------------------------------- /backend-ts/src/api/sentry/setup.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import express from 'express'; 3 | 4 | import Organization from '../../models/Organization.model'; 5 | import SentryInstallation from '../../models/SentryInstallation.model'; 6 | 7 | export type TokenResponseData = { 8 | expiresAt: string; // ISO date string at which token must be refreshed 9 | token: string; // Bearer token authorized to make Sentry API requests 10 | refreshToken: string; // Refresh token required to get a new Bearer token after expiration 11 | }; 12 | 13 | export type InstallResponseData = { 14 | app: { 15 | uuid: string; // UUID for your application (shared across installations) 16 | slug: string; // URL slug for your application (shared across installations) 17 | }; 18 | organization: { 19 | slug: string; // URL slug for the organization doing the installation 20 | }; 21 | uuid: string; // UUID for the individual installation 22 | }; 23 | 24 | const router = express.Router(); 25 | 26 | router.post('/', async (req, res) => { 27 | // Destructure the all the body params we receive from the installation prompt 28 | const { 29 | code, 30 | installationId, 31 | sentryOrgSlug, 32 | // Our frontend application tells us which organization to associate the install with 33 | organizationId, 34 | } = req.body; 35 | 36 | // Construct a payload to ask Sentry for a token on the basis that a user is installing 37 | const payload = { 38 | grant_type: 'authorization_code', 39 | code, 40 | client_id: process.env.SENTRY_CLIENT_ID, 41 | client_secret: process.env.SENTRY_CLIENT_SECRET, 42 | }; 43 | 44 | // Send that payload to Sentry and parse its response 45 | const tokenResponse: {data: TokenResponseData} = await axios.post( 46 | `${process.env.SENTRY_URL}/api/0/sentry-app-installations/${installationId}/authorizations/`, 47 | payload 48 | ); 49 | 50 | // Store the tokenData (i.e. token, refreshToken, expiresAt) for future requests 51 | // - Make sure to associate the installationId and the tokenData since it's unique to the organization 52 | // - Using the wrong token for the a different installation will result 401 Unauthorized responses 53 | const {token, refreshToken, expiresAt} = tokenResponse.data; 54 | const organization = await Organization.findByPk(organizationId); 55 | await SentryInstallation.create({ 56 | uuid: installationId as string, 57 | orgSlug: sentryOrgSlug as string, 58 | expiresAt: new Date(expiresAt), 59 | token, 60 | refreshToken, 61 | organizationId: organization.id, 62 | }); 63 | 64 | // Verify the installation to inform Sentry of the success 65 | // - This step is only required if you have enabled 'Verify Installation' on your integration 66 | const verifyResponse: {data: InstallResponseData} = await axios.put( 67 | `${process.env.SENTRY_URL}/api/0/sentry-app-installations/${installationId}/`, 68 | {status: 'installed'}, 69 | { 70 | headers: { 71 | Authorization: `Bearer ${token}`, 72 | }, 73 | } 74 | ); 75 | 76 | // Update the associated organization to connect it to Sentry's organization 77 | organization.externalSlug = sentryOrgSlug; 78 | await organization.save(); 79 | 80 | // Continue the installation process 81 | // - If your app requires additional configuration, this is where you can do it 82 | // - The token/refreshToken can be used to make requests to Sentry's API 83 | // - Once you're done, you can optionally redirect the user back to Sentry as we do below 84 | console.info(`Installed ${verifyResponse.data.app.slug} on '${organization.name}'`); 85 | res.status(201).send({ 86 | redirectUrl: `${process.env.SENTRY_URL}/settings/${sentryOrgSlug}/sentry-apps/${verifyResponse.data.app.slug}/`, 87 | }); 88 | }); 89 | 90 | export default router; 91 | -------------------------------------------------------------------------------- /backend-ts/src/api/users.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import Organization from '../models/Organization.model'; 4 | import User from '../models/User.model'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get('/', async (request, response) => { 9 | const {organization: slug} = request.query; 10 | if (slug) { 11 | const organization = await Organization.findOne({ 12 | include: User, 13 | where: {slug}, 14 | }); 15 | if (organization) { 16 | return response.send(organization.users); 17 | } 18 | } 19 | const users = await User.findAll(); 20 | return response.send(users); 21 | }); 22 | 23 | router.post('/', async (request, response) => { 24 | const {name, username, avatar, organizationId} = request.body; 25 | const user = await User.create({name, username, avatar, organizationId}); 26 | return response.status(201).send(user); 27 | }); 28 | 29 | router.put('/:userId', async (request, response) => { 30 | const {name, username, avatar, organizationId} = request.body; 31 | const user = await User.update( 32 | {name, username, avatar, organizationId}, 33 | {where: {id: request.params.userId}} 34 | ); 35 | return response.send(user); 36 | }); 37 | 38 | router.delete('/:userId', async (request, response) => { 39 | await User.destroy({where: {id: request.params.userId}}); 40 | return response.sendStatus(204); 41 | }); 42 | 43 | export default router; 44 | -------------------------------------------------------------------------------- /backend-ts/src/index.ts: -------------------------------------------------------------------------------- 1 | import {sequelize} from './models'; 2 | import createServer from './server'; 3 | 4 | sequelize 5 | .authenticate() 6 | .then(() => sequelize.sync()) 7 | .then(() => { 8 | const server = createServer(); 9 | server.listen(process.env.EXPRESS_LISTEN_PORT, () => { 10 | console.info( 11 | `Server started at http://localhost:${process.env.EXPRESS_LISTEN_PORT}` 12 | ); 13 | }); 14 | }) 15 | .catch(e => console.error(`[🔌 DB Connection Error]: ${e.message}`)); 16 | -------------------------------------------------------------------------------- /backend-ts/src/models/Item.model.ts: -------------------------------------------------------------------------------- 1 | import {Column, DataType, Default, ForeignKey, Model, Table} from 'sequelize-typescript'; 2 | 3 | import Organization from './Organization.model'; 4 | import User from './User.model'; 5 | 6 | export enum ItemColumn { 7 | Todo = 'TODO', 8 | Doing = 'DOING', 9 | Done = 'DONE', 10 | } 11 | 12 | export type ItemComment = { 13 | text: string; 14 | author: string; 15 | timestamp: string; 16 | sentryCommentId: string; 17 | }; 18 | 19 | @Table({tableName: 'item', underscored: true, timestamps: false}) 20 | export default class Item extends Model { 21 | @Column 22 | title: string; 23 | 24 | @Column(DataType.TEXT) 25 | description: string; 26 | 27 | @Column 28 | complexity: number; 29 | 30 | @Default(false) 31 | @Column 32 | isIgnored: boolean; 33 | 34 | @Column 35 | sentryId: string; 36 | 37 | @Column 38 | sentryAlertId: string; 39 | 40 | @Default([]) 41 | @Column(DataType.JSON) 42 | comments: ItemComment[]; 43 | 44 | @Default(ItemColumn.Todo) 45 | @Column({type: DataType.ENUM({values: Object.values(ItemColumn)})}) 46 | column: ItemColumn; 47 | 48 | @ForeignKey(() => User) 49 | @Column 50 | assigneeId: number; 51 | 52 | @ForeignKey(() => Organization) 53 | @Column 54 | organizationId: number; 55 | } 56 | -------------------------------------------------------------------------------- /backend-ts/src/models/Organization.model.ts: -------------------------------------------------------------------------------- 1 | import {Column, HasMany, Model, Table} from 'sequelize-typescript'; 2 | 3 | import Item from './Item.model'; 4 | import SentryInstallation from './SentryInstallation.model'; 5 | import User from './User.model'; 6 | 7 | @Table({tableName: 'organization', underscored: true, timestamps: false}) 8 | export default class Organization extends Model { 9 | @Column 10 | name: string; 11 | 12 | @Column 13 | slug: string; 14 | 15 | @Column 16 | externalSlug: string; 17 | 18 | @HasMany(() => User) 19 | users: User[]; 20 | 21 | @HasMany(() => Item) 22 | items: Item[]; 23 | 24 | @HasMany(() => SentryInstallation) 25 | sentryInstallations: SentryInstallation[]; 26 | } 27 | -------------------------------------------------------------------------------- /backend-ts/src/models/SentryInstallation.model.ts: -------------------------------------------------------------------------------- 1 | import {Column, ForeignKey, Model, Table} from 'sequelize-typescript'; 2 | 3 | import Organization from './Organization.model'; 4 | 5 | @Table({tableName: 'sentry_installation', underscored: true, timestamps: false}) 6 | export default class SentryInstallation extends Model { 7 | @Column 8 | uuid: string; 9 | 10 | @Column 11 | orgSlug: string; 12 | 13 | @Column 14 | token: string; 15 | 16 | @Column 17 | refreshToken: string; 18 | 19 | @Column 20 | expiresAt: Date; 21 | 22 | @ForeignKey(() => Organization) 23 | @Column 24 | organizationId: number; 25 | } 26 | -------------------------------------------------------------------------------- /backend-ts/src/models/User.model.ts: -------------------------------------------------------------------------------- 1 | import {Column, ForeignKey, HasMany, Model, Table} from 'sequelize-typescript'; 2 | 3 | import Item from './Item.model'; 4 | import Organization from './Organization.model'; 5 | 6 | @Table({tableName: 'user', underscored: true, timestamps: false}) 7 | export default class User extends Model { 8 | @Column 9 | name: string; 10 | 11 | @Column 12 | username: string; 13 | 14 | @Column 15 | avatar: string; 16 | 17 | @HasMany(() => Item) 18 | items: Item[]; 19 | 20 | @ForeignKey(() => Organization) 21 | @Column 22 | organizationId: number; 23 | } 24 | -------------------------------------------------------------------------------- /backend-ts/src/models/index.ts: -------------------------------------------------------------------------------- 1 | import {config} from 'dotenv'; 2 | import path from 'path'; 3 | import {Sequelize} from 'sequelize-typescript'; 4 | 5 | const sequelizeConfig = { 6 | host: 'database', // Note: This must match the container name for the Docker bridge network to connect properly 7 | port: 5432, 8 | }; 9 | 10 | // We modify the Sequelize config to point to our test-database 11 | if (process.env.NODE_ENV === 'test') { 12 | config({path: path.resolve(__dirname, '../../../.env')}); 13 | sequelizeConfig.host = '127.0.0.1'; 14 | sequelizeConfig.port = parseInt(process.env.TEST_DB_PORT); 15 | } 16 | 17 | const {POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB} = process.env; 18 | 19 | // Connect our ORM to the database. 20 | const sequelize = new Sequelize(POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD, { 21 | dialect: 'postgres', 22 | logging: false, 23 | models: [__dirname + '/**/*.model.ts'], 24 | ...sequelizeConfig, 25 | }); 26 | 27 | export {sequelize}; 28 | -------------------------------------------------------------------------------- /backend-ts/src/server.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import express from 'express'; 3 | 4 | import apiRoutes from './api'; 5 | 6 | function createServer() { 7 | const server = express(); 8 | server.use(cors()); 9 | server.use(express.json()); 10 | server.get('/', (req, res) => res.sendStatus(200)); 11 | server.use('/api', apiRoutes); 12 | return server; 13 | } 14 | 15 | export default createServer; 16 | -------------------------------------------------------------------------------- /backend-ts/src/util/SentryAPIClient.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosResponse, Method} from 'axios'; 2 | 3 | import {TokenResponseData} from '../api/sentry/setup'; 4 | import Organization from '../models/Organization.model'; 5 | import SentryInstallation from '../models/SentryInstallation.model'; 6 | 7 | class SentryAPIClient { 8 | private token: string; 9 | 10 | constructor(token: string) { 11 | this.token = token; 12 | } 13 | 14 | /** 15 | * Fetches an organization's Sentry API token, refreshing it if necessary. 16 | */ 17 | static async getSentryAPIToken(organization: Organization) { 18 | const sentryInstallation = await SentryInstallation.findOne({ 19 | where: {organizationId: organization.id}, 20 | }); 21 | 22 | // If the token is not expired, no need to refresh it 23 | if (sentryInstallation.expiresAt.getTime() > Date.now()) { 24 | return sentryInstallation.token; 25 | } 26 | 27 | // If the token is expired, we'll need to refresh it... 28 | console.info(`Token for '${sentryInstallation.orgSlug}' has expired. Refreshing...`); 29 | // Construct a payload to ask Sentry for a new token 30 | const payload = { 31 | grant_type: 'refresh_token', 32 | refresh_token: sentryInstallation.refreshToken, 33 | client_id: process.env.SENTRY_CLIENT_ID, 34 | client_secret: process.env.SENTRY_CLIENT_SECRET, 35 | }; 36 | 37 | // Send that payload to Sentry and parse the response 38 | const tokenResponse: {data: TokenResponseData} = await axios.post( 39 | `${process.env.SENTRY_URL}/api/0/sentry-app-installations/${sentryInstallation.uuid}/authorizations/`, 40 | payload 41 | ); 42 | 43 | // Store the token information for future requests 44 | const {token, refreshToken, expiresAt} = tokenResponse.data; 45 | const updatedSentryInstallation = await sentryInstallation.update({ 46 | token, 47 | refreshToken, 48 | expiresAt: new Date(expiresAt), 49 | }); 50 | console.info(`Token for '${updatedSentryInstallation.orgSlug}' has been refreshed.`); 51 | 52 | // Return the newly refreshed token 53 | return updatedSentryInstallation.token; 54 | } 55 | 56 | // We create static wrapper on the constructor to ensure our token is always refreshed 57 | static async create(organization: Organization) { 58 | const token = await SentryAPIClient.getSentryAPIToken(organization); 59 | return new SentryAPIClient(token); 60 | } 61 | 62 | public async request( 63 | method: Method, 64 | path: string, 65 | data?: object 66 | ): Promise { 67 | const response = await axios({ 68 | method, 69 | url: `${process.env.SENTRY_URL}/api/0${path}`, 70 | headers: {Authorization: `Bearer ${this.token}`}, 71 | data, 72 | }).catch(e => { 73 | // TODO(you): Catch these sorta errors in Sentry! 74 | console.error('A request to the Sentry API failed:', { 75 | status: e.response.status, 76 | statusText: e.response.statusText, 77 | data: e.response.data, 78 | }); 79 | }); 80 | return response; 81 | } 82 | 83 | public get(path: string): Promise { 84 | return this.request('GET', path); 85 | } 86 | 87 | // TODO(you): Extend as you see fit! 88 | } 89 | 90 | export default SentryAPIClient; 91 | -------------------------------------------------------------------------------- /backend-ts/tests/api/organizations.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Express} from 'express'; 3 | import request from 'supertest'; 4 | 5 | import createOrganization, {Organization} from '../factories/Organization.factory'; 6 | import {closeTestServer, createTestServer} from '../testutils'; 7 | 8 | const path = '/api/organizations/'; 9 | 10 | describe(path, () => { 11 | let server: Express; 12 | let organizations: Organization[]; 13 | const organizationNames = ['Example 1', 'Example 2']; 14 | 15 | beforeEach(async () => { 16 | server = await createTestServer(); 17 | organizations = await Promise.all( 18 | organizationNames.map(async name => await createOrganization({name})) 19 | ); 20 | jest.resetAllMocks(); 21 | }); 22 | 23 | afterAll(async () => await closeTestServer()); 24 | 25 | it('handles GET', async () => { 26 | const response = await request(server).get(path); 27 | assert.equal(response.body.length, organizationNames.length); 28 | assert.equal(response.statusCode, 200); 29 | }); 30 | 31 | it('handles POST', async () => { 32 | const response = await request(server).post(path).send({name: 'Example 4'}); 33 | assert.equal(response.statusCode, 201); 34 | assert(await Organization.findByPk(response.body.id)); 35 | }); 36 | 37 | it('handles PUT', async () => { 38 | const response = await request(server) 39 | .put(`${path}${organizations[0].slug}`) 40 | .send({name: 'Example 5'}); 41 | assert.equal(response.statusCode, 200); 42 | const organization = await Organization.findByPk(organizations[0].id); 43 | assert.equal(organization.name, 'Example 5'); 44 | }); 45 | 46 | it('handles DELETE', async () => { 47 | const response = await request(server).delete(`${path}${organizations[0].slug}`); 48 | assert.equal(response.statusCode, 204); 49 | const organization = await Organization.findByPk(organizations[0].id); 50 | assert(!organization); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /backend-ts/tests/api/sentry/alertRuleAction.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Express} from 'express'; 3 | import request from 'supertest'; 4 | 5 | import createSentryInstallation from '../../factories/SentryInstallation.factory'; 6 | import createUser from '../../factories/User.factory'; 7 | import {closeTestServer, createTestServer} from '../../testutils'; 8 | import {ALERT_RULE_ACTION_VALUES, MOCK_ALERT_RULE_ACTION, UUID} from './../../mocks'; 9 | 10 | const path = '/api/sentry/alert-rule-action'; 11 | 12 | describe(`POST ${path}`, () => { 13 | let server: Express; 14 | 15 | beforeEach(async () => { 16 | server = await createTestServer(); 17 | jest.resetAllMocks(); 18 | }); 19 | 20 | afterAll(async () => await closeTestServer()); 21 | 22 | it('handles successfully approving alert rule changes in Sentry', async () => { 23 | const sentryInstallation = await createSentryInstallation({uuid: UUID}); 24 | await createUser({ 25 | id: ALERT_RULE_ACTION_VALUES.userId, 26 | organizationId: sentryInstallation.organizationId, 27 | }); 28 | const response = await request(server).post(path).send(MOCK_ALERT_RULE_ACTION); 29 | assert.equal(response.statusCode, 200); 30 | }); 31 | 32 | it('handles successfully surfacing errors in Sentry', async () => { 33 | let response = await request(server).post(path).send({}); 34 | assert.equal(response.statusCode, 400); 35 | assert.equal(response.body.message, 'Invalid installation was provided'); 36 | 37 | await createSentryInstallation({uuid: UUID}); 38 | response = await request(server) 39 | .post(path) 40 | .send({...MOCK_ALERT_RULE_ACTION, fields: []}); 41 | assert.equal(response.statusCode, 400); 42 | assert.equal(response.body.message, 'Title and description are required'); 43 | 44 | response = await request(server).post(path).send(MOCK_ALERT_RULE_ACTION); 45 | assert.equal(response.statusCode, 400); 46 | assert.equal(response.body.message, 'Selected user was not found'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /backend-ts/tests/api/sentry/handlers/issueHandler.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Express} from 'express'; 3 | import request from 'supertest'; 4 | 5 | import {Item, ItemColumn} from '../../../factories/Item.factory'; 6 | import createSentryInstallation, { 7 | SentryInstallation, 8 | } from '../../../factories/SentryInstallation.factory'; 9 | import {User} from '../../../factories/User.factory'; 10 | import {closeTestServer, createTestServer} from '../../../testutils'; 11 | import {ISSUE, MOCK_WEBHOOK, UUID} from './../../../mocks'; 12 | 13 | const path = '/api/sentry/webhook/'; 14 | 15 | describe(`issueHandler for webhooks`, () => { 16 | let server: Express; 17 | let baseRequest: request.Test; 18 | let sentryInstallation: SentryInstallation; 19 | 20 | beforeEach(async () => { 21 | server = await createTestServer(); 22 | sentryInstallation = await createSentryInstallation({uuid: UUID}); 23 | baseRequest = request(server).post(path).set({'sentry-hook-resource': 'issue'}); 24 | jest.resetAllMocks(); 25 | }); 26 | 27 | afterAll(async () => await closeTestServer()); 28 | 29 | it('should handle issue.assigned events', async () => { 30 | const response = await baseRequest.send(MOCK_WEBHOOK['issue.assigned']); 31 | assert.equal(response.statusCode, 202); 32 | const item = await Item.findOne({where: {sentryId: ISSUE.id}}); 33 | expect(item).not.toBeNull(); 34 | const user = await User.findOne({where: {username: ISSUE.assignedTo.email}}); 35 | expect(user).not.toBeNull(); 36 | expect(item.assigneeId).toEqual(user.id); 37 | expect(item.column).toEqual(ItemColumn.Doing); 38 | }); 39 | 40 | it('should handle issue.created events', async () => { 41 | const response = await baseRequest.send(MOCK_WEBHOOK['issue.created']); 42 | assert.equal(response.statusCode, 201); 43 | const item = await Item.findOne({where: {sentryId: ISSUE.id}}); 44 | expect(item).not.toBeNull(); 45 | assert.equal(item.title, ISSUE.title); 46 | assert.equal(item.description, `${ISSUE.shortId} - ${ISSUE.culprit}`); 47 | assert.equal(item.isIgnored, false); 48 | assert.equal(item.column, ItemColumn.Todo); 49 | assert.equal(item.sentryId, ISSUE.id); 50 | assert.equal(item.organizationId, sentryInstallation.organizationId); 51 | }); 52 | 53 | it('should handle issue.ignored events', async () => { 54 | const response = await baseRequest.send(MOCK_WEBHOOK['issue.ignored']); 55 | assert.equal(response.statusCode, 202); 56 | const item = await Item.findOne({where: {sentryId: ISSUE.id}}); 57 | expect(item).not.toBeNull(); 58 | assert.equal(item.isIgnored, true); 59 | }); 60 | 61 | it('should handle issue.resolved events', async () => { 62 | const response = await baseRequest.send(MOCK_WEBHOOK['issue.resolved']); 63 | assert.equal(response.statusCode, 202); 64 | const item = await Item.findOne({where: {sentryId: ISSUE.id}}); 65 | assert.equal(item.column, ItemColumn.Done); 66 | }); 67 | 68 | it('should handle unknown action events', async () => { 69 | const response = await baseRequest.send({ 70 | ...MOCK_WEBHOOK['issue.assigned'], 71 | action: 'bookmarked', 72 | }); 73 | assert.equal(response.statusCode, 400); 74 | const item = await Item.findOne({where: {sentryId: ISSUE.id}}); 75 | expect(item).toBeNull(); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /backend-ts/tests/api/sentry/issueLink.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Express} from 'express'; 3 | import request from 'supertest'; 4 | 5 | import createItem, {Item} from '../../factories/Item.factory'; 6 | import createOrganization, {Organization} from '../../factories/Organization.factory'; 7 | import createSentryInstallation from '../../factories/SentryInstallation.factory'; 8 | import {closeTestServer, createTestServer} from '../../testutils'; 9 | import {MOCK_ISSUE_LINK, UUID} from './../../mocks'; 10 | 11 | const path = '/api/sentry/issue-link'; 12 | 13 | describe(`GET ${path}`, () => { 14 | let server: Express; 15 | let organization: Organization; 16 | let item: Item; 17 | 18 | beforeEach(async () => { 19 | server = await createTestServer(); 20 | jest.resetAllMocks(); 21 | organization = await createOrganization(); 22 | item = await createItem({organizationId: organization.id}); 23 | await createSentryInstallation({uuid: UUID, organizationId: organization.id}); 24 | }); 25 | 26 | afterAll(async () => await closeTestServer()); 27 | 28 | it('handles creating Sentry Issue Links', async () => { 29 | // Check that the response was appropriate 30 | const response = await request(server).post(`${path}/create`).send(MOCK_ISSUE_LINK); 31 | assert.equal(response.statusCode, 201); 32 | expect(response.body.webUrl); 33 | assert.equal(response.body.project, 'ACME'); 34 | // Check that item was created properly 35 | const itemId = response.body.identifier; 36 | const newItem = await Item.findByPk(itemId); 37 | assert.equal(newItem.title, MOCK_ISSUE_LINK.fields.title); 38 | assert.equal(newItem.description, MOCK_ISSUE_LINK.fields.description); 39 | assert.equal(newItem.column, MOCK_ISSUE_LINK.fields.column); 40 | assert.equal(newItem.complexity, MOCK_ISSUE_LINK.fields.complexity); 41 | assert.equal(newItem.sentryId, MOCK_ISSUE_LINK.issueId); 42 | }); 43 | 44 | it('handles linking to Sentry Issue Links', async () => { 45 | const payload = MOCK_ISSUE_LINK; 46 | payload.fields.itemId = item.id; 47 | assert.equal(item.sentryId, null); 48 | // Check that the existing item was updated 49 | const response = await request(server).post(`${path}/link`).send(payload); 50 | await item.reload(); 51 | assert.equal(response.statusCode, 200); 52 | assert.equal(item.sentryId, payload.issueId); 53 | // Check that the response was appropriate 54 | expect(response.body.webUrl); 55 | assert.equal(response.body.project, 'ACME'); 56 | assert.equal(response.body.identifier, item.id); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /backend-ts/tests/api/sentry/options.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Express} from 'express'; 3 | import request from 'supertest'; 4 | 5 | import createItem from '../../factories/Item.factory'; 6 | import createOrganization, {Organization} from '../../factories/Organization.factory'; 7 | import createSentryInstallation from '../../factories/SentryInstallation.factory'; 8 | import {closeTestServer, createTestServer} from '../../testutils'; 9 | import {UUID} from './../../mocks'; 10 | 11 | const path = '/api/sentry/options/items/'; 12 | 13 | describe(`GET ${path}`, () => { 14 | let server: Express; 15 | let organization: Organization; 16 | const initialItemCount = 1; 17 | 18 | beforeEach(async () => { 19 | server = await createTestServer(); 20 | jest.resetAllMocks(); 21 | organization = await createOrganization(); 22 | await createItem({organizationId: organization.id}); 23 | await createSentryInstallation({uuid: UUID, organizationId: organization.id}); 24 | }); 25 | 26 | afterAll(async () => await closeTestServer()); 27 | 28 | it('responds with the proper format', async () => { 29 | const response = await request(server).get(path).query({installationId: UUID}); 30 | assert.equal(response.statusCode, 200); 31 | assert.equal(response.body.length, initialItemCount); 32 | // Check that the options are all valid 33 | for (const option of response.body) { 34 | expect(option).toHaveProperty('value'); 35 | expect(option).toHaveProperty('label'); 36 | } 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /backend-ts/tests/api/sentry/setup.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import axios from 'axios'; 3 | import {Express} from 'express'; 4 | import request, {Response} from 'supertest'; 5 | 6 | import createOrganization from '../../factories/Organization.factory'; 7 | import {closeTestServer, createTestServer} from '../../testutils'; 8 | 9 | const path = '/api/sentry/setup/'; 10 | 11 | describe(`POST ${path}`, () => { 12 | let server: Express; 13 | let response: Response; 14 | const mockPost = jest.spyOn(axios, 'post'); 15 | const mockPut = jest.spyOn(axios, 'put'); 16 | 17 | beforeEach(async () => { 18 | server = await createTestServer(); 19 | jest.resetAllMocks(); 20 | const {newToken, installation, installBody} = sentryMocks; 21 | await createOrganization({id: installBody.organizationId}); 22 | mockPost.mockResolvedValue(newToken); 23 | mockPut.mockResolvedValue(installation); 24 | response = await request(server).post(path).send(installBody); 25 | }); 26 | 27 | afterAll(async () => await closeTestServer()); 28 | 29 | it('responds with a 201, and a redirect URL', async () => { 30 | assert.equal(response.statusCode, 201); 31 | assert(response.body.redirectUrl); 32 | }); 33 | 34 | it('properly requests a token', async () => { 35 | const [endpoint, payload] = mockPost.mock.calls[0]; 36 | assert(endpoint.includes('sentry-app-installations')); 37 | assert(Object.keys(payload).includes('client_id')); 38 | assert(Object.keys(payload).includes('client_secret')); 39 | }); 40 | 41 | it('properly verifies the installation', async () => { 42 | const [endpoint, payload, options] = mockPut.mock.calls[0] as any[]; 43 | assert(endpoint.includes('sentry-app-installations')); 44 | assert.equal(payload.status, 'installed'); 45 | const authHeader = options.headers.Authorization as string; 46 | assert(authHeader.includes(sentryMocks.newToken.data.token)); 47 | }); 48 | }); 49 | 50 | const sentryMocks = { 51 | installBody: { 52 | code: 'installCode', 53 | installationId: 'abc123', 54 | organizationId: '1', 55 | sentryOrgSlug: 'example', 56 | }, 57 | newToken: { 58 | data: { 59 | token: 'abc123', 60 | refreshToken: 'def456', 61 | expiresAt: '2022-01-01T08:00:00.000Z', 62 | }, 63 | }, 64 | installation: { 65 | data: { 66 | app: { 67 | uuid: '64bf2cf4-37ca-4365-8dd3-6b6e56d741b8', 68 | slug: 'app', 69 | }, 70 | organization: {slug: 'example'}, 71 | uuid: '7a485448-a9e2-4c85-8a3c-4f44175783c9', 72 | }, 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /backend-ts/tests/api/sentry/webhook.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Express} from 'express'; 3 | import request from 'supertest'; 4 | 5 | import createSentryInstallation, { 6 | SentryInstallation, 7 | } from '../../factories/SentryInstallation.factory'; 8 | import {closeTestServer, createTestServer} from '../../testutils'; 9 | import {MOCK_WEBHOOK, UUID} from './../../mocks'; 10 | 11 | const path = '/api/sentry/webhook/'; 12 | 13 | describe(`GET ${path}`, () => { 14 | let server: Express; 15 | 16 | beforeEach(async () => { 17 | server = await createTestServer(); 18 | jest.resetAllMocks(); 19 | }); 20 | 21 | afterAll(async () => await closeTestServer()); 22 | 23 | it('responds with a 400 to bad requests', async () => { 24 | let response = await request(server).post(path); 25 | assert.equal(response.statusCode, 400); 26 | response = await request(server).post(path).send('malformed'); 27 | assert.equal(response.statusCode, 400); 28 | await request(server).post(path).send(MOCK_WEBHOOK['installation.deleted']); // missing headers 29 | assert.equal(response.statusCode, 400); 30 | }); 31 | 32 | it('responds with a 400 to unknown installations', async () => { 33 | const response = await request(server) 34 | .post(path) 35 | .send(MOCK_WEBHOOK['installation.deleted']) 36 | .set({'sentry-hook-resource': 'installation'}); 37 | assert.equal(response.statusCode, 404); 38 | }); 39 | 40 | it('handles installation.deleted', async () => { 41 | await createSentryInstallation({uuid: UUID}); 42 | const newInstall = await SentryInstallation.findOne({where: {uuid: UUID}}); 43 | expect(newInstall).not.toBeNull(); 44 | await request(server) 45 | .post(path) 46 | .send(MOCK_WEBHOOK['installation.deleted']) 47 | .set({'sentry-hook-resource': 'installation'}); 48 | const oldInstall = await SentryInstallation.findOne({where: {uuid: UUID}}); 49 | expect(oldInstall).toBeNull(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /backend-ts/tests/api/users.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import {Express} from 'express'; 3 | import request from 'supertest'; 4 | 5 | import createOrganization from '../factories/Organization.factory'; 6 | import createUser, {User} from '../factories/User.factory'; 7 | import {closeTestServer, createTestServer} from '../testutils'; 8 | 9 | const path = '/api/users/'; 10 | 11 | describe(path, () => { 12 | let server: Express; 13 | let users: User[]; 14 | const userNames = ['Person 1', 'Person 2']; 15 | 16 | beforeEach(async () => { 17 | server = await createTestServer(); 18 | users = await Promise.all(userNames.map(async name => await createUser({name}))); 19 | jest.resetAllMocks(); 20 | }); 21 | 22 | afterAll(async () => await closeTestServer()); 23 | 24 | it('handles GET', async () => { 25 | const response = await request(server).get(path); 26 | assert.equal(response.body.length, userNames.length); 27 | assert.equal(response.statusCode, 200); 28 | }); 29 | 30 | it('handles GET by organization', async () => { 31 | const organization = await createOrganization({slug: 'example'}); 32 | await createUser({name: 'Person 3', organizationId: organization.id}); 33 | const response = await request(server).get(path).query({organization: 'example'}); 34 | assert.equal(response.body.length, 1); 35 | }); 36 | 37 | it('handles POST', async () => { 38 | const organization = await createOrganization(); 39 | const response = await request(server) 40 | .post(path) 41 | .send({name: 'Person 4', organizationId: organization.id}); 42 | assert.equal(response.statusCode, 201); 43 | assert(await User.findByPk(response.body.id)); 44 | // Ensure relationships were properly set 45 | const organizationUsers = await organization.$get('users'); 46 | assert.equal(organizationUsers.length, 1); 47 | assert.equal(organizationUsers[0].name, 'Person 4'); 48 | }); 49 | 50 | it('handles PUT', async () => { 51 | const organization = await createOrganization(); 52 | const response = await request(server) 53 | .put(`${path}${users[0].id}`) 54 | .send({name: 'Person 5', organizationId: organization.id}); 55 | assert.equal(response.statusCode, 200); 56 | const user = await User.findByPk(users[0].id); 57 | assert.equal(user.name, 'Person 5'); 58 | // Ensure relationships were properly set 59 | const organizationUsers = await organization.$get('users'); 60 | assert.equal(organizationUsers.length, 1); 61 | assert.equal(organizationUsers[0].name, user.name); 62 | }); 63 | 64 | it('handles DELETE', async () => { 65 | const response = await request(server).delete(`${path}${users[0].id}`); 66 | assert.equal(response.statusCode, 204); 67 | const user = await User.findByPk(users[0].id); 68 | assert(!user); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /backend-ts/tests/factories/Item.factory.ts: -------------------------------------------------------------------------------- 1 | import {Attributes} from 'sequelize'; 2 | 3 | import Item, {ItemColumn} from '../../src/models/Item.model'; 4 | import createOrganization from './Organization.factory'; 5 | import createUser from './User.factory'; 6 | 7 | export default async function createItem( 8 | fields: Partial> = {} 9 | ): Promise { 10 | if (!fields.organizationId) { 11 | const organization = await createOrganization(); 12 | fields.organizationId = organization.id; 13 | } 14 | if (!fields.assigneeId) { 15 | const user = await createUser({organizationId: fields.organizationId}); 16 | fields.assigneeId = user.id; 17 | } 18 | return Item.create({ 19 | title: 'Error: Module not Found', 20 | description: 'Module was not found when loading application...', 21 | complexity: 3, 22 | column: ItemColumn.Todo, 23 | ...fields, 24 | }); 25 | } 26 | 27 | export {Item, ItemColumn}; 28 | -------------------------------------------------------------------------------- /backend-ts/tests/factories/Organization.factory.ts: -------------------------------------------------------------------------------- 1 | import {Attributes} from 'sequelize'; 2 | 3 | import Organization from '../../src/models/Organization.model'; 4 | 5 | export default async function createOrganization( 6 | fields: Partial> = {} 7 | ): Promise { 8 | return Organization.create({ 9 | name: 'example', 10 | slug: 'example', 11 | externalSlug: 'sentry-example', 12 | ...fields, 13 | }); 14 | } 15 | export {Organization}; 16 | -------------------------------------------------------------------------------- /backend-ts/tests/factories/SentryInstallation.factory.ts: -------------------------------------------------------------------------------- 1 | import {Attributes} from 'sequelize'; 2 | 3 | import SentryInstallation from '../../src/models/SentryInstallation.model'; 4 | import {INSTALLATION} from '../mocks'; 5 | import createOrganization from './Organization.factory'; 6 | 7 | export default async function createSentryInstallation( 8 | fields: Partial> = {} 9 | ): Promise { 10 | if (!fields.organizationId) { 11 | const organization = await createOrganization(); 12 | fields.organizationId = organization.id; 13 | } 14 | return SentryInstallation.create({ 15 | uuid: INSTALLATION.uuid, 16 | orgSlug: INSTALLATION.organization.slug, 17 | token: 'abcdef123456abcdef123456abcdef123456', 18 | refreshToken: 'abcdef123456abcdef123456abcdef123456', 19 | expiresAt: new Date(2200, 0, 1), 20 | ...fields, 21 | }); 22 | } 23 | export {SentryInstallation}; 24 | -------------------------------------------------------------------------------- /backend-ts/tests/factories/User.factory.ts: -------------------------------------------------------------------------------- 1 | import {Attributes} from 'sequelize'; 2 | 3 | import User from '../../src/models/User.model'; 4 | import createOrganization from './Organization.factory'; 5 | 6 | export default async function createUser( 7 | fields: Partial> = {} 8 | ): Promise { 9 | if (!fields.organizationId) { 10 | const organization = await createOrganization(); 11 | fields.organizationId = organization.id; 12 | } 13 | return User.create({ 14 | name: 'Name', 15 | username: 'username', 16 | avatar: 'https://example.com/avatar', 17 | ...fields, 18 | }); 19 | } 20 | export {User}; 21 | -------------------------------------------------------------------------------- /backend-ts/tests/server.spec.ts: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import request from 'supertest'; 3 | 4 | import createServer from '../src/server'; 5 | 6 | describe('GET /', () => { 7 | it('responds with a 200', async () => { 8 | const server = createServer(); 9 | const response = await request(server).get('/'); 10 | assert.equal(response.statusCode, 200); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /backend-ts/tests/testutils.ts: -------------------------------------------------------------------------------- 1 | import {sequelize} from '../src/models'; 2 | import createServer from '../src/server'; 3 | 4 | export const createTestServer = async () => { 5 | await sequelize.authenticate(); 6 | await sequelize.sync({force: true}); 7 | return createServer(); 8 | }; 9 | 10 | export const closeTestServer = async () => { 11 | await sequelize.close(); 12 | }; 13 | -------------------------------------------------------------------------------- /backend-ts/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "esModuleInterop": true, 5 | "resolveJsonModule": true, 6 | "experimentalDecorators": true, 7 | "emitDecoratorMetadata": true, 8 | "target": "es6", 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "outDir": "dist", 13 | "baseUrl": ".", 14 | "paths": { 15 | "*": ["node_modules/*"] 16 | } 17 | }, 18 | "include": ["src/**/*", "tests/**/*"] 19 | } 20 | -------------------------------------------------------------------------------- /data/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM postgres:14.2 as database 2 | 3 | WORKDIR /data 4 | 5 | # Initialize the database with the schema.sql file 6 | COPY scripts/schema.sql ../docker-entrypoint-initdb.d/init.sql 7 | -------------------------------------------------------------------------------- /data/scripts/clear.sql: -------------------------------------------------------------------------------- 1 | -- Truncating the 'organization' table with CASCADE will also truncate 'item', 'user' and 'sentry_installation' tables. 2 | 3 | TRUNCATE organization CASCADE; 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | backend-py: 4 | container_name: backend-py 5 | env_file: 6 | - .env 7 | depends_on: 8 | database: 9 | condition: service_healthy 10 | build: 11 | context: backend-py 12 | target: backend-py 13 | ports: 14 | - "${FLASK_RUN_PORT}:${FLASK_RUN_PORT}" 15 | # Watch and react to changes in local... 16 | volumes: 17 | # backend-py/src directory 18 | - type: bind 19 | source: ./backend-py/src 20 | target: /backend-py/src 21 | # data directory 22 | - type: bind 23 | source: ./data 24 | target: /data 25 | 26 | backend-ts: 27 | container_name: backend-ts 28 | env_file: 29 | - .env 30 | depends_on: 31 | database: 32 | condition: service_healthy 33 | build: 34 | context: backend-ts 35 | target: backend-ts 36 | ports: 37 | - "${EXPRESS_LISTEN_PORT}:${EXPRESS_LISTEN_PORT}" 38 | # Watch and react to changes in local... 39 | volumes: 40 | # backend-ts/src directory 41 | - type: bind 42 | source: ./backend-ts/src 43 | target: /backend-ts/src 44 | # data directory 45 | - type: bind 46 | source: ./data 47 | target: /data 48 | 49 | frontend: 50 | container_name: frontend 51 | env_file: 52 | - .env 53 | build: 54 | context: frontend 55 | target: frontend 56 | ports: 57 | - "${REACT_APP_PORT}:3000" 58 | # Watch and react to changes in local... 59 | volumes: 60 | # frontend/src directory 61 | - type: bind 62 | source: ./frontend/src 63 | target: /frontend/src 64 | # data directory 65 | - type: bind 66 | source: ./data 67 | target: /data 68 | 69 | database: 70 | container_name: database 71 | env_file: 72 | - .env 73 | build: 74 | context: data 75 | target: database 76 | healthcheck: 77 | test: ["CMD-SHELL", "pg_isready"] 78 | start_period: 10s 79 | interval: 10s 80 | retries: 5 81 | timeout: 5s 82 | ports: 83 | - "${DB_PORT}:5432" 84 | volumes: 85 | # Create a managed docker volume to persist data if the container shuts down 86 | - db:/var/lib/postgresql/data 87 | # Watch and react to changes in local data directory 88 | - type: bind 89 | source: ./data 90 | target: /data 91 | 92 | test-database: 93 | container_name: test-database 94 | env_file: 95 | - .env 96 | environment: 97 | POSTGRES_HOST_AUTH_METHOD: trust 98 | image: postgres:14.2 99 | ports: 100 | - "${TEST_DB_PORT}:5432" 101 | 102 | volumes: 103 | db: 104 | -------------------------------------------------------------------------------- /docs/api-usage.md: -------------------------------------------------------------------------------- 1 | # API Usage 2 | 3 | This document will show you how to test this application's usage of the [Sentry API](https://docs.sentry.io/api/) and it's ability to refresh tokens. 4 | 5 | ## Testing 6 | 7 | 1. Ensure this application is running (`make serve-typescript` or `make serve-python`) and has been [installed on your organization in Sentry](../installation.md). 8 | 2. Select an organization's kanban to view 9 | 3. Link a Sentry issue with a kanban item 10 | - This can be done via the [issue webhooks](./event-webhooks.md#issue-webhooks) or [issue linking](../ui-components/issue-linking.md) 11 | 4. Once linked, refresh the kanban app. 12 | 5. The linked issue should appear with a `SHORT-ID` instead of the numerical ID we save to the database 13 | - This is replaced on the frontend by using the API Token that has been issued to our installation 14 | 6. To test token refreshing, modify the row in your database to manually expire the token. 15 | - E.g. Postgres DB Client > `sentrydemo` (default) > `sentry_installations` > `expires_at` 16 | 7. Now refresh the kanban app. 17 | 8. If the `SHORT-ID` still appears as a badge, our token was successfully refreshed. 18 | 19 | 20 | ## Code Insights 21 | 22 | If you monitor server logs during the above install-uninstall test, you should see something similar to the following: 23 | 24 | ``` 25 | Token for leander-test has expired. 26 | Token for 'leander-test' has been refreshed. 27 | ``` 28 | 29 | These logs are created as part of the primitive Sentry API Client we've included in the repository. Here is where you'll find the code responsible for token refresh as well. 30 | - [Python Sentry API Client](../backend-py/src/util/sentry_api_client.py) 31 | - [TypeScript Sentry API Client](../backend-ts/src/util/SentryAPIClient.ts) 32 | 33 | The only endpoint we enrich with Sentry API data is `/api/items`, which populates the items in the kanban app. 34 | - [Python Sentry Data Usage](../backend-py/src/api/endpoints/items.py) 35 | - [TypeScript Sentry Data Usage](../backend-ts/src/api/items.ts) 36 | -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | # Installation / Uninstallation 2 | 3 | > **Note**: Internal integrations do not need to perform installation/verification since they are automatically installed on the authoring organization. See [the docs](https://docs.sentry.io/product/integrations/integration-platform/internal-integration/) for details. 4 | 5 | ## Testing 6 | 7 | To test the installation flow, navigate to your Sentry instance and do the following: 8 | 9 | 1. Click Settings > Integrations 10 | 2. Click on your integration 11 | - This is the page your users will see when they search for your integration on Sentry 12 | - You can update the info on this page via Settings > Developer Settings > Your Integration 13 | 3. Click Accept & Install 14 | - If you've specified a Redirect URL on your integration, you should be sent there now 15 | - For this demo, we did specify a Redirect URL so you should arrive at the Frontend ngrok forwarding address 16 | 4. Select a Demo Organization to link to your Sentry Organization 17 | 5. Click Submit 18 | - You'll be redirected to Sentry after this. While optional, we recommend developers do this so users can confirm themselves that the installation was successful from both sides. 19 | 20 | To test the uninstallation flow: 21 | 22 | 1. Navigate to your integration's installation (Settings > Integrations > Your Integration) 23 | 2. Click Uninstall 24 | 3. Click Confirm 25 | 26 | 27 | ## Code Insights 28 | 29 | If you monitor server logs during the above install-uninstall test, you should see something similar to the following: 30 | 31 | ``` 32 | # Installation Request 33 | 34 | Authorized: Verified request came from Sentry 35 | Received 'installation.created' webhook from Sentry 36 | Installed example on 'Bahringer LLC' 37 | 38 | # Uninstallation Request 39 | 40 | Authorized: Verified request came from Sentry 41 | Received 'installation.deleted' webhook from Sentry 42 | Uninstalled example from 'Bahringer LLC' 43 | ``` 44 | 45 | The authorization logs comes from verifying the request signature with the shared secret 46 | - [Python Signature Verification](../backend-py/src/api/middleware/verify_sentry_signature.py) 47 | - [TypeScript Signature Verification](../backend-ts/src/api/middleware/verifySentrySignature.ts) 48 | 49 | The `installation.created` webhook is fine to ignore since we have set up a custom endpoint to which our Redirect URL's form submits: 50 | - [Python Installation Handling](../backend-py/src/api/endpoints/sentry/setup.py) 51 | - [Typescript Installation Handling](../backend-ts/src/api/sentry/setup.ts) 52 | 53 | The 'Installed app on organization' log confirms that we've verified the installation with Sentry 54 | 55 | The `installation.deleted` webhook must be handled to remove the associated installation/token data 56 | - [Python Uninstallation Handling](../backend-py/src/api/endpoints/sentry/webhook.py) 57 | - [Typescript Uninstallation Handling](../backend-ts/src/api/sentry/webhook.ts) 58 | 59 | The 'Uninstalled app from organization' log confirms that we've removed the Sentry installation from our database 60 | -------------------------------------------------------------------------------- /docs/reference-implementation-frontend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/integration-platform-example/10cf516b6bb68f5055e6c0f6ce84c07530a8c267/docs/reference-implementation-frontend.png -------------------------------------------------------------------------------- /docs/ui-components/alert-rule-actions.md: -------------------------------------------------------------------------------- 1 | # Alert Rule Actions UI Component 2 | 3 | ## Setup 4 | 5 | 1. Ensure this application is running (`make serve-typescript` or `make serve-python`) and has been [installed on your organization in Sentry](../installation.md). 6 | 2. Create a new alert (Alerts > Create Alert) 7 | 3. To setup a test issue alert, select 'Issues' then 'Set Conditions' 8 | 1. Give the rule a name, and a the 'A new issue is created' trigger 9 | 2. In the action dropdown, select your integration 10 | 4. To setup a test metric alert, select 'Number of Errors' then 'Set Conditions' 11 | 1. Set the critical, warning and resolved thresholds to `3`, `2` and `1` respectively 12 | 2. Click 'Add Action' and select the trigger you'd like to test (e.g. 'Critical Status') 13 | 3. Click the default action (e.g. 'Email') and select your integration 14 | 4. Remember to give your rule a name 15 | 5. Click the 'Settings' button and fill in the form appropriately 16 | - The form fields in this modal come from the [`integration-schema.json` file](../../integration-schema.json), specifically the blob with `"type": "alert-rule-settings"` 17 | 6. Save the changes to your integration's setting form. 18 | 7. Save the alert rule. 19 | 20 | ## Testing 21 | 22 | For testing, refer to [Alert Webhooks docs](../webhooks/alert-webhooks.md#testing). 23 | 24 | ## Code Insights 25 | 26 | 27 | For code insights, refer to [Alert Webhooks docs](../webhooks/alert-webhooks.md#code-insights). 28 | 29 | It should be noted that the only difference in alert webhook consumption when Alert Rule Actions are enabled is the precense of extra, custom data on each payload. See [the public docs](https://docs.sentry.io/product/integrations/integration-platform/ui-components/alert-rule-action) for more info. 30 | -------------------------------------------------------------------------------- /docs/ui-components/issue-linking.md: -------------------------------------------------------------------------------- 1 | # Issue Linking UI Component 2 | 3 | ## Testing 4 | 5 | 1. Ensure this application is running (`make serve-typescript` or `make serve-python`) and has been [installed on your organization in Sentry](../installation.md). 6 | 2. Navigate to any issue details page (Issues > Click on an Issue) 7 | 3. Once the page loads, find the 'Issue Tracking' section in the right side bar 8 | 4. Find your integration's Issue Link button 9 | - It will depend on what you named your integration (i.e. 'Link APP_NAME Issue') 10 | 5. Click it to bring up your application's Issue Link modal 11 | - The form fields in the 'Create' and 'Link' tabs of this modal come from the [`integration-schema.json` file](../../integration-schema.json), specifically the blob with `"type": "issue-link"` 12 | 6. Submit the appropriate form ('Create' to spawn a new ticket, 'Link' to update an existing one) to finalize the Issue Link on Sentry 13 | 7. Once submitted, the Issue Link should update to reflect the identifiers our app (e.g. 'ACME#1'). 14 | 8. Click this link to be directed to the relevant kanban board 15 | 9. Confirm the link by ensuring the item you created/selected has the appropriate Sentry ID attached to it. 16 | 17 | ## Code Insights 18 | 19 | If you monitor the server logs while using the Issue Linking UI component in Sentry, you should see something similar to the following: 20 | 21 | ``` 22 | # Initial load of the UI component 23 | 24 | Authorized: Verified request came from Sentry 25 | Populating item options in Sentry 26 | 27 | # Submitting the 'Create' form 28 | 29 | Authorized: Verified request came from Sentry 30 | Created item through Sentry Issue Link UI Component 31 | 32 | # Submitting the 'Link' form 33 | 34 | Authorized: Verified request came from Sentry 35 | Linked item through Sentry Issue Link UI Component 36 | ``` 37 | 38 | All the authorization logs are coming from middleware which verifies the request signature with the shared secret: 39 | - [Python Signature Verification](../../backend-py/src/api/middleware/verify_sentry_signature.py) 40 | - [TypeScript Signature Verification](../../backend-ts/src/api/middleware/verifySentrySignature.ts) 41 | 42 | The 'Populating item options' log comes from the select field we specify in the schema: 43 | - [Integration Schema](../../integration-schema.json) (Look at the blob under `elements[0].link.required_fields`) 44 | 45 | It tells Sentry what endpoint to ping and use to populate options in a Select field. 46 | - [Python Options Response Code](../../backend-py/src/api/endpoints/sentry/options.py) 47 | - [TypeScript Options Response Code](../../backend-ts/src/api/sentry/options.ts) 48 | 49 | The 'Created/Linked item' logs come from Sentry pinging another endpoint we specify in the schema: 50 | - [Integration Schema](../../integration-schema.json) (Look at the `uri` property under `elements[0].link` and `elements[0].create`) 51 | - You can modify the payload that gets sent to these `uri` by editing the `required_fields` and `optional_fields` in the corresponding JSON blob 52 | 53 | When a user in Sentry submits the create/link form, the payload gets sent to the URIs specified in those fields of the schema. 54 | - [Python Create/Link Handling](../../backend-py/src/api/endpoints/sentry/issue_link.py) 55 | - [TypeScript Create/Link Handling](../../backend-ts/src/api/sentry/issueLink.ts) 56 | -------------------------------------------------------------------------------- /docs/webhooks/comment-webhooks.md: -------------------------------------------------------------------------------- 1 | # Comment Webhooks 2 | 3 | ## Testing 4 | 5 | 1. Ensure this application is running (`make serve-typescript` or `make serve-python`) and has been [installed on your organization in Sentry](../installation.md). 6 | 2. Link a Sentry issue with a kanban item 7 | - This can be done via the [issue webhooks](./event-webhooks.md#issue-webhooks) or [issue linking](../ui-components/issue-linking.md) 8 | 3. Once linked, open the issue details page in Sentry 9 | 4. Go to the 'Activity' tab 10 | 5. Trigger the `comment.created` webhook by leaving a comment on the activity log 11 | 6. Switch to the kanban app and refresh to see the new comment. 12 | 7. Trigger the `comment.updated` webhook by hovering your comment, clicking the 'Edit' button, and submitting a change. 13 | 8. Refresh again to see the modification to the comment. 14 | 9. Trigger the `comment.deleted` webhook by hovering your comment,clicking the 'Remove' button, and confirming. 15 | 10. Refresh one last time to verify that the comment has been deleted. 16 | 17 | ## Code Insights 18 | 19 | If you monitor server logs during the above issue webhook suite test, you should see something similar to the following: 20 | 21 | ``` 22 | Authorized: Verified request came from Sentry 23 | 24 | Received 'comment.created' webhook from Sentry 25 | Added new comment from Sentry issue 26 | 27 | Received 'comment.updated' webhook from Sentry 28 | Updated comment from Sentry issue 29 | 30 | Received 'comment.deleted' webhook from Sentry 31 | Deleted comment from Sentry issue 32 | ``` 33 | 34 | Broadly, the steps in handling these webhooks are as follows: 35 | 36 | 1. Verify the signature. The authorization comes from verifying the request signature with the shared secret 37 | - [Python Signature Verification](../../backend-py/src/api/middleware/verify_sentry_signature.py) 38 | - [TypeScript Signature Verification](../../backend-ts/src/api/middleware/verifySentrySignature.ts) 39 | 40 | 2. Logging the type of webhook the application is receiving before handling it. This is helpful just for debugging and sanity checking. 41 | - [Python Webhook Logging](../../backend-py/src/api/endpoints/sentry/webhook.py) 42 | - [TypeScript Webhook Logging](../../backend-ts/src/api/sentry/webhook.ts) 43 | 44 | 3. Pass the webhook along to a dedicated handler to keep the webhook endpoint clean 45 | - [Python Comment Webhook Handler](../../backend-py/src/api/endpoints/sentry/handlers/comment_handler.py) 46 | - [TypeScript Comment Webhook Handler](../../backend-ts/src/api/sentry/handlers/commentHandler.ts) 47 | 48 | 4. Check if the issue exists in our application 49 | - In this example, we drop comments that occur on Sentry issues that haven't been linked, with log similar to `Ignoring comment for unlinked Sentry issue` 50 | 51 | 5. Act on the webhook 52 | - Acting on every one of these webhooks in your application for a linked issue can create a 'comment-sync' experience for your user 53 | - Leverage linking the issue as soon as it appears in Sentry via [issue](./event-webhooks.md#issue-webhooks) and [alert](./alert-webhooks.md). If you need more custom data, see the [Alert Rule Action UI Component docs](../ui-components/alert-rule-actions.md) 54 | 55 | 56 | 6. Respond with an appropriate status code 57 | - The integration dashboard in Sentry will reflect these status codes for more debugging help 58 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:import/recommended', 9 | 'plugin:import/typescript', 10 | 'plugin:react/recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 'latest', 19 | sourceType: 'module', 20 | }, 21 | plugins: ['react', '@typescript-eslint', 'prettier', 'simple-import-sort'], 22 | rules: { 23 | 'prettier/prettier': 'error', 24 | 'simple-import-sort/imports': 'error', 25 | 'simple-import-sort/exports': 'error', 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | }, 28 | settings: { 29 | react: { 30 | version: 'detect', 31 | }, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | bracketSpacing: false, 3 | printWidth: 90, 4 | semi: true, 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'es5', 8 | useTabs: false, 9 | arrowParens: 'avoid', 10 | }; 11 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16.13 as frontend 2 | # Installing packages... 3 | WORKDIR /frontend 4 | COPY package.json . 5 | COPY package-lock.json . 6 | RUN npm install 7 | # Copy source files after installing packages 8 | COPY . . 9 | # Describing the environment... 10 | ENV NODE_ENV=developement 11 | # Preparing startup 12 | CMD ["npm", "start"] 13 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Frontend - TypeScript 2 | 3 | This directory contains the frontend code written in TypeScript. 4 | 5 | It was bootstrapped from [create-react-app](https://create-react-app.dev/). We opted to use Typescript via: 6 | ```shell 7 | npx create-react-app frontend --template typescript 8 | ``` 9 | See more [here](https://create-react-app.dev/docs/adding-typescript/). 10 | 11 | 12 | ## Development 13 | 14 | To start, you'll need to install [Docker](https://docs.docker.com/engine/install/) and ensure it is running. 15 | 16 | Then, to spin up this service individually: 17 | 18 | ```bash 19 | docker compose up frontend 20 | ``` 21 | 22 | If adding dependencies or changing the environment variables, be sure to rebuild the image. We suggest using [Volta](https://volta.sh/) to manage your node version when installing packages. 23 | 24 | ```bash 25 | npm install my-package 26 | docker compose build frontend 27 | ``` 28 | 29 | ## Testing 30 | 31 | To check for linting errors, run the following in this directory: 32 | 33 | ```bash 34 | npm run lint 35 | ``` 36 | 37 | To run all tests, run the following command in this directory: 38 | 39 | ```bash 40 | npm run test 41 | ``` 42 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "integration-platform-frontend", 3 | "version": "0.0.0", 4 | "private": true, 5 | "repository": { 6 | "type": "git", 7 | "url": "git://github.com/getsentry/integration-platform-example.git" 8 | }, 9 | "scripts": { 10 | "start": "react-scripts start", 11 | "build": "react-scripts build", 12 | "test": "react-scripts test", 13 | "eject": "react-scripts eject", 14 | "lint": "eslint . --ext .ts,.tsx && prettier -c src", 15 | "lint:fix": "eslint . --ext .ts,.tsx --fix && prettier -c src --write" 16 | }, 17 | "eslintConfig": { 18 | "extends": [ 19 | "react-app", 20 | "react-app/jest" 21 | ] 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | }, 35 | "dependencies": { 36 | "@emotion/react": "^11.8.2", 37 | "@emotion/styled": "^11.8.1", 38 | "@sentry/react": "^6.19.2", 39 | "@sentry/tracing": "^6.19.2", 40 | "@testing-library/jest-dom": "^5.16.2", 41 | "@testing-library/react": "^12.1.3", 42 | "@types/jest": "^27.4.0", 43 | "@types/node": "^16.11.25", 44 | "@types/react": "^17.0.39", 45 | "@types/react-dom": "^17.0.11", 46 | "react": "^17.0.2", 47 | "react-dom": "^17.0.2", 48 | "react-router-dom": "^6.2.2", 49 | "react-scripts": "5.0.0", 50 | "react-select": "^5.2.2", 51 | "typescript": "^4.5.5", 52 | "web-vitals": "^2.1.4" 53 | }, 54 | "devDependencies": { 55 | "@testing-library/dom": "^8.11.3", 56 | "@testing-library/user-event": "^13.5.0", 57 | "@types/jest-when": "^3.5.0", 58 | "@typescript-eslint/eslint-plugin": "^5.12.1", 59 | "@typescript-eslint/parser": "^5.12.1", 60 | "eslint": "^8.9.0", 61 | "eslint-plugin-import": "^2.25.4", 62 | "eslint-plugin-prettier": "^4.0.0", 63 | "eslint-plugin-react": "^7.28.0", 64 | "eslint-plugin-simple-import-sort": "^7.0.0", 65 | "jest-when": "^3.5.1", 66 | "prettier": "2.5.1" 67 | }, 68 | "volta": { 69 | "node": "16.13.1" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getsentry/integration-platform-example/10cf516b6bb68f5055e6c0f6ce84c07530a8c267/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | ACME Kanban 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import {ThemeProvider} from '@emotion/react'; 2 | import React from 'react'; 3 | import {BrowserRouter as Router, Route, Routes} from 'react-router-dom'; 4 | 5 | import KanbanPage from './pages/KanbanPage'; 6 | import LandingPage from './pages/LandingPage'; 7 | import SetupPage from './pages/SetupPage'; 8 | import GlobalStyles from './styles/GlobalStyles'; 9 | import {darkTheme, lightTheme} from './styles/theme'; 10 | 11 | function App() { 12 | const lightThemeMediaQuery = window.matchMedia('(prefers-color-scheme: light)'); 13 | return ( 14 | 15 | 16 | 17 | 18 | } /> 19 | } /> 20 | } /> 21 | 22 | 23 | 24 | ); 25 | } 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /frontend/src/components/BasePage.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | 4 | import Footer from '../components/Footer'; 5 | 6 | function BasePage({children}: {children: React.ReactNode}) { 7 | return ( 8 | 9 | {children} 10 |