├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── help-request.md └── workflows │ ├── bot.yml │ ├── docs.yml │ ├── infrastructure.yml │ ├── web_api.yml │ └── web_ui.yml ├── .gitignore ├── .kodiak.toml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── design.dot ├── design.svg └── logo.png ├── bot ├── .coveragerc ├── .dockerignore ├── .pylintrc ├── .python-version ├── .vscode │ ├── extensions.json │ └── settings.json ├── Dockerfile ├── README.md ├── docker-compose.yml ├── example.env ├── kodiak │ ├── __init__.py │ ├── app_config.py │ ├── assertions.py │ ├── cli.py │ ├── config.py │ ├── conftest.py │ ├── dependencies.py │ ├── entrypoints │ │ ├── __init__.py │ │ ├── ingest.py │ │ └── worker.py │ ├── errors.py │ ├── evaluation.py │ ├── events │ │ ├── README.md │ │ ├── __init__.py │ │ ├── base.py │ │ ├── check_run.py │ │ ├── pull_request.py │ │ ├── pull_request_review.py │ │ ├── pull_request_review_thread.py │ │ ├── push.py │ │ └── status.py │ ├── http.py │ ├── logging.py │ ├── messages.py │ ├── pull_request.py │ ├── queries │ │ ├── __init__.py │ │ └── commits.py │ ├── queue.py │ ├── redis_client.py │ ├── refresh_pull_requests.py │ ├── schemas.py │ ├── test │ │ └── fixtures │ │ │ ├── api │ │ │ └── get_event │ │ │ │ ├── behind.json │ │ │ │ ├── no_author.json │ │ │ │ └── no_latest_sha.json │ │ │ ├── config │ │ │ ├── config-schema.json │ │ │ ├── v1-default.toml │ │ │ ├── v1-opposite.1.toml │ │ │ └── v1-opposite.2.toml │ │ │ ├── config_utils │ │ │ ├── pydantic-error.md │ │ │ └── toml-error.md │ │ │ └── events │ │ │ ├── check_run │ │ │ ├── check_run_completed.json │ │ │ ├── check_run_created.json │ │ │ ├── check_run_created_incomplete.json │ │ │ ├── check_run_event.json │ │ │ └── check_run_event_pull_requests.json │ │ │ ├── pull_request │ │ │ ├── assigned.json │ │ │ ├── closed.json │ │ │ ├── edited.json │ │ │ ├── labeled.json │ │ │ ├── labeled_full.json │ │ │ ├── opened_draft.json │ │ │ ├── opened_reviewers_assignees_milestone_project.json │ │ │ ├── pr_opened.json │ │ │ ├── pull_request_event.json │ │ │ ├── review_requested.json │ │ │ └── synchronize.json │ │ │ ├── pull_request_review │ │ │ ├── approved.json │ │ │ ├── changes_requested.json │ │ │ ├── commented.json │ │ │ ├── pull_request_review_event.json │ │ │ └── pull_request_review_thread_event.json │ │ │ ├── push │ │ │ ├── create_patch.json │ │ │ ├── master.json │ │ │ └── push_event.json │ │ │ └── status │ │ │ ├── missing_target_url.json │ │ │ ├── pending.json │ │ │ ├── short.json │ │ │ ├── status_event.json │ │ │ └── success.json │ ├── test_config.py │ ├── test_config_utils.py │ ├── test_evaluation.py │ ├── test_event_handlers.py │ ├── test_events.py │ ├── test_logging.py │ ├── test_main.py │ ├── test_pull_request.py │ ├── test_queries.py │ ├── test_queue.py │ ├── test_text.py │ ├── test_utils.py │ ├── tests │ │ ├── __init__.py │ │ ├── dependencies │ │ │ ├── __init__.py │ │ │ ├── pull_requests │ │ │ │ ├── update-major-github_action.txt │ │ │ │ ├── update-major-two_packages.txt │ │ │ │ ├── update-major-two_packages_2.txt │ │ │ │ ├── update-minor-single_package.txt │ │ │ │ ├── update-patch-dependabot_batch.txt │ │ │ │ ├── update-patch-dependabot_single.txt │ │ │ │ └── update-patch-lock_file_maintenance.txt │ │ │ ├── test_dependabot.py │ │ │ ├── test_dependencies.py │ │ │ └── test_renovate.py │ │ ├── evaluation │ │ │ ├── test_automerge_dependencies.py │ │ │ ├── test_branch_protection.py │ │ │ ├── test_check_runs.py │ │ │ ├── test_merge_message.py │ │ │ ├── test_merge_message_cut_body.py │ │ │ ├── test_merge_message_trailers.py │ │ │ └── test_merge_method.py │ │ ├── event_handlers │ │ │ ├── __init__.py │ │ │ └── test_check_run.py │ │ ├── fixtures.py │ │ ├── test_fixtures.py │ │ └── test_messages.py │ ├── text.py │ └── throttle.py ├── poetry.lock ├── pyproject.toml ├── s │ ├── dev-ingest │ ├── dev-workers │ ├── fmt │ ├── lint │ ├── test │ ├── typecheck │ └── upload-code-cov ├── supervisord.conf ├── tox.ini └── typings │ ├── jwt.pyi │ ├── markdown_html_finder.pyi │ ├── markupsafe.pyi │ ├── rure │ ├── __init__.pyi │ └── exceptions.pyi │ ├── structlog │ ├── __init__.pyi │ ├── _base.pyi │ ├── _config.pyi │ ├── _generic.pyi │ ├── processors.pyi │ └── stdlib.pyi │ ├── uvicorn │ ├── __init__.pyi │ └── main.pyi │ └── zstandard.pyi ├── codecov.yml ├── docs ├── .prettierrc.js ├── README.md ├── core │ └── Footer.js ├── docs │ ├── billing.md │ ├── config-reference.md │ ├── contributing.md │ ├── dashboard.md │ ├── features.md │ ├── permissions.md │ ├── prior-art-and-alternatives.md │ ├── quickstart.md │ ├── recipes.md │ ├── self-hosting.md │ ├── sponsoring.md │ ├── troubleshooting.md │ └── why-and-how.md ├── netlify.toml ├── package.json ├── pages │ └── en │ │ ├── help.js │ │ └── index.js ├── s │ ├── build │ ├── dev │ ├── fmt │ ├── fmt-ci │ └── typecheck ├── sidebars.json ├── siteConfig.js ├── static │ ├── css │ │ └── custom.css │ └── img │ │ ├── billing-modify-card-step-1.png │ │ ├── billing-modify-card-step-2.png │ │ ├── billing-modify-card-step-3.png │ │ ├── branch-protection-require-branches-up-to-date.png │ │ ├── branch-protection-require-signed-commits.png │ │ ├── coauthors-example.png │ │ ├── dashboard │ │ ├── billing-trial-signup.png │ │ ├── kodiak-activity.png │ │ ├── merge-queue.png │ │ ├── overview.png │ │ ├── pull-request-activity.png │ │ ├── trial-signup.png │ │ └── usage.png │ │ ├── favicon.ico │ │ ├── kodiak-pr-flow.svg │ │ ├── logo_complexgmbh.png │ │ ├── restrict-who-can-push-to-matching-branches.png │ │ ├── undraw_code_review.svg │ │ ├── undraw_uploading.svg │ │ └── wordmark.png ├── tsconfig.json └── yarn.lock ├── infrastructure ├── README.md ├── kodiak-daily-restart.service ├── kodiak-daily-restart.timer ├── kodiak_restart.sh ├── playbooks │ └── dashboard-deploy.yml └── systemd │ ├── kodiak-aggregate_pull_request_activity.service.j2 │ ├── kodiak-aggregate_pull_request_activity.timer │ ├── kodiak-aggregate_user_pull_request_activity.service.j2 │ └── kodiak-aggregate_user_pull_request_activity.timer ├── kodiak.code-workspace ├── s └── shellcheck ├── web_api ├── .coveragerc ├── .dockerignore ├── .pylintrc ├── .python-version ├── .vscode │ └── settings.json ├── Dockerfile ├── README.md ├── example.env ├── manage.py ├── poetry.lock ├── pyproject.toml ├── s │ ├── dev │ ├── fmt │ ├── lint │ ├── squawk.py │ ├── test │ └── upload-code-cov ├── tox.ini ├── typings │ └── zstandard.pyi └── web_api │ ├── __init__.py │ ├── asgi.py │ ├── auth.py │ ├── conftest.py │ ├── event_ingestion.py │ ├── exceptions.py │ ├── http.py │ ├── management │ └── commands │ │ ├── aggregate_pull_request_activity.py │ │ ├── aggregate_user_pull_request_activity.py │ │ └── ingest_events.py │ ├── merge_queues.py │ ├── middleware.py │ ├── migrations │ ├── 0001_initial.py │ ├── 0002_githubevent.py │ ├── 0003_auto_20200215_1733.py │ ├── 0004_auto_20200215_2015.py │ ├── 0005_auto_20200215_2156.py │ ├── 0006_remove_account_payload.py │ ├── 0007_auto_20200217_0345.py │ ├── 0008_payload_installation_id_idx.py │ ├── 0009_pullrequestactivityprogress.py │ ├── 0010_auto_20200220_0116.py │ ├── 0011_accountmembership_role.py │ ├── 0012_auto_20200308_2254.py │ ├── 0013_auto_20200310_0412.py │ ├── 0014_auto_20200323_0159.py │ ├── 0015_remove_stripecustomerinformation_customer_delinquent.py │ ├── 0016_auto_20200405_1511.py │ ├── 0017_account_trial_email.py │ ├── 0018_auto_20200502_1849.py │ ├── 0019_auto_20200610_0006.py │ ├── 0020_auto_20200613_2012.py │ ├── 0021_auto_20200617_1246.py │ ├── 0022_remove_account_stripe_plan_id.py │ ├── 0023_account_limit_billing_access_to_owners.py │ ├── 0024_auto_20200726_0316.py │ ├── 0025_auto_20200902_0052.py │ ├── 0026_auto_20220322_0036.py │ └── __init__.py │ ├── models.py │ ├── patches.py │ ├── settings.py │ ├── test_account.py │ ├── test_analytics_aggregator.py │ ├── test_merge_queues.py │ ├── test_middleware.py │ ├── test_pull_request_activity.py │ ├── test_stripe_customer_info.py │ ├── test_stripe_views.py │ ├── test_user.py │ ├── test_user_pull_request_activity.py │ ├── test_views.py │ ├── tests │ └── fixtures │ │ ├── pull_request_kodiak_merged.json │ │ ├── pull_request_kodiak_updated.json │ │ ├── pull_request_kodiak_updated_different_institution.json │ │ ├── pull_request_review_kodiak_approved.json │ │ ├── pull_request_total_closed.json │ │ ├── pull_request_total_merged.json │ │ ├── pull_request_total_opened.json │ │ └── pull_request_total_opened_different_institution.json │ ├── testutils.py │ ├── urls.py │ ├── user_activity_aggregator.py │ ├── utils.py │ ├── views.py │ └── wsgi.py └── web_ui ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode └── settings.json ├── Dockerfile ├── README.md ├── config ├── env.js ├── jest │ ├── cssTransform.js │ └── fileTransform.js ├── modules.js ├── paths.js ├── pnpTs.js ├── webpack.config.js └── webpackDevServer.config.js ├── general_headers.conf ├── netlify.toml ├── nginx.conf ├── package.json ├── public ├── favicon.ico └── index.html ├── s ├── dev ├── fmt ├── lint ├── test └── typecheck ├── scripts ├── build.js ├── start.js └── test.js ├── src ├── api.ts ├── auth.ts ├── components │ ├── AccountsPage.tsx │ ├── ActivityChart.tsx │ ├── ActivityPage.tsx │ ├── App.tsx │ ├── DebugSentryPage.tsx │ ├── ErrorBoundary.tsx │ ├── Image.tsx │ ├── LoginPage.test.tsx │ ├── LoginPage.tsx │ ├── NotFoundPage.tsx │ ├── OAuthPage.tsx │ ├── Page.tsx │ ├── SideBarNav.tsx │ ├── Spinner.tsx │ ├── SubscriptionAlert.tsx │ ├── ToolTip.tsx │ ├── UsageBillingPage.tsx │ └── __snapshots__ │ │ └── LoginPage.test.tsx.snap ├── custom.scss ├── index.tsx ├── settings.ts ├── setupTests.ts ├── useApi.ts ├── webdata.ts └── world.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: chdsbd # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/help-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Help request 3 | about: Ask for help configuring and using Kodiak 4 | title: '' 5 | labels: help-request 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/bot.yml: -------------------------------------------------------------------------------- 1 | name: bot 2 | 3 | on: 4 | pull_request: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref || github.run_id }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | pre_job: 12 | runs-on: ubuntu-latest 13 | outputs: 14 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 15 | paths_result: ${{ steps.skip_check.outputs.paths_result }} 16 | steps: 17 | - id: skip_check 18 | uses: fkirc/skip-duplicate-actions@c449d86cf33a2a6c7a4193264cc2578e2c3266d4 # pin@v4 19 | with: 20 | paths: '["bot/**", ".github/workflows/**"]' 21 | 22 | test: 23 | needs: pre_job 24 | if: needs.pre_job.outputs.should_skip != 'true' 25 | runs-on: ubuntu-latest 26 | services: 27 | redis: 28 | image: redis:5 29 | # Set health checks to wait until redis has started 30 | options: >- 31 | --health-cmd "redis-cli ping" 32 | --health-interval 10s 33 | --health-timeout 5s 34 | --health-retries 5 35 | ports: 36 | # Maps port 6379 on service container to the host 37 | - 6379:6379 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Install poetry 41 | run: | 42 | pipx install poetry==1.1.13 43 | poetry config virtualenvs.in-project true 44 | - uses: actions/setup-python@v4 45 | with: 46 | python-version-file: "./bot/.python-version" 47 | cache: poetry 48 | cache-dependency-path: "./bot/poetry.lock" 49 | - name: Install dependencies 50 | working-directory: "./bot" 51 | run: poetry install 52 | - name: Run tests 53 | working-directory: "bot" 54 | run: ./s/test 55 | - name: upload code coverage 56 | working-directory: bot 57 | run: ./s/upload-code-cov 58 | lint: 59 | needs: pre_job 60 | if: needs.pre_job.outputs.should_skip != 'true' 61 | runs-on: ubuntu-latest 62 | steps: 63 | - uses: actions/checkout@v3 64 | - name: Install poetry 65 | run: | 66 | pipx install poetry==1.1.13 67 | poetry config virtualenvs.in-project true 68 | - uses: actions/setup-python@v4 69 | with: 70 | python-version-file: "./bot/.python-version" 71 | cache: poetry 72 | cache-dependency-path: "./bot/poetry.lock" 73 | - name: Install dependencies 74 | working-directory: "./bot" 75 | run: poetry install 76 | - name: Run lints 77 | working-directory: "bot" 78 | run: ./s/lint 79 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: docs 2 | 3 | on: 4 | pull_request: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref || github.run_id }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | pre_job: 12 | runs-on: ubuntu-latest 13 | outputs: 14 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 15 | paths_result: ${{ steps.skip_check.outputs.paths_result }} 16 | steps: 17 | - id: skip_check 18 | uses: fkirc/skip-duplicate-actions@c449d86cf33a2a6c7a4193264cc2578e2c3266d4 # pin@v4 19 | with: 20 | paths: '["docs/**", ".github/workflows/**"]' 21 | typecheck: 22 | needs: pre_job 23 | if: needs.pre_job.outputs.should_skip != 'true' 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version-file: "docs/package.json" 30 | cache-dependency-path: "docs/yarn.lock" 31 | cache: "yarn" 32 | - name: Install dependencies 33 | working-directory: "docs" 34 | run: yarn install --frozen-lockfile 35 | - name: run typechecker 36 | working-directory: "docs" 37 | run: ./s/typecheck 38 | 39 | fmt: 40 | needs: pre_job 41 | if: needs.pre_job.outputs.should_skip != 'true' 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | - uses: actions/setup-node@v3 46 | with: 47 | node-version-file: "docs/package.json" 48 | cache-dependency-path: "docs/yarn.lock" 49 | cache: "yarn" 50 | - name: Install dependencies 51 | working-directory: "docs" 52 | run: yarn install --frozen-lockfile 53 | - name: Run tests 54 | working-directory: "docs" 55 | run: ./s/fmt-ci 56 | 57 | verify_build: 58 | needs: pre_job 59 | if: needs.pre_job.outputs.should_skip != 'true' 60 | runs-on: ubuntu-latest 61 | steps: 62 | - uses: actions/checkout@v3 63 | - uses: actions/setup-node@v3 64 | with: 65 | node-version-file: "docs/package.json" 66 | cache-dependency-path: "docs/yarn.lock" 67 | cache: "yarn" 68 | - name: Install dependencies 69 | working-directory: "docs" 70 | run: yarn install --frozen-lockfile 71 | - name: Run tests 72 | working-directory: "docs" 73 | run: ./s/build 74 | -------------------------------------------------------------------------------- /.github/workflows/web_ui.yml: -------------------------------------------------------------------------------- 1 | name: web_ui 2 | 3 | on: 4 | pull_request: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref || github.run_id }} 8 | cancel-in-progress: true 9 | 10 | jobs: 11 | pre_job: 12 | runs-on: ubuntu-latest 13 | outputs: 14 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 15 | paths_result: ${{ steps.skip_check.outputs.paths_result }} 16 | steps: 17 | - id: skip_check 18 | uses: fkirc/skip-duplicate-actions@c449d86cf33a2a6c7a4193264cc2578e2c3266d4 # pin@v4 19 | with: 20 | paths: '["web_ui/**", ".github/workflows/**"]' 21 | test: 22 | needs: pre_job 23 | if: needs.pre_job.outputs.should_skip != 'true' 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v3 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version-file: "web_ui/package.json" 30 | cache-dependency-path: "web_ui/yarn.lock" 31 | cache: "yarn" 32 | - name: Install dependencies 33 | working-directory: "web_ui" 34 | run: yarn install --frozen-lockfile 35 | - name: Run tests 36 | working-directory: "web_ui" 37 | run: ./s/test 38 | 39 | lint: 40 | needs: pre_job 41 | if: needs.pre_job.outputs.should_skip != 'true' 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@v3 45 | - uses: actions/setup-node@v3 46 | with: 47 | node-version-file: "web_ui/package.json" 48 | cache-dependency-path: "web_ui/yarn.lock" 49 | cache: "yarn" 50 | - name: Install dependencies 51 | working-directory: "web_ui" 52 | run: yarn install --frozen-lockfile 53 | - name: Run tests 54 | working-directory: "web_ui" 55 | run: ./s/lint 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | # .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | 125 | *.pem 126 | i18n 127 | -------------------------------------------------------------------------------- /.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge.message] 4 | title = "pull_request_title" 5 | body = "pull_request_body" 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | # kodiak 4 | 5 | > A GitHub bot to automatically update and merge GitHub PRs 6 | 7 | [install app](https://github.com/marketplace/kodiakhq) | [documentation](https://kodiakhq.com/docs/quickstart) | [web dashboard](https://app.kodiakhq.com) 8 | 9 | Automate your GitHub Pull Requests 10 | 11 | - Auto Update – Keep your PRs up to date with `master` automatically 12 | - Auto Merge – Add the `automerge` label to auto merge once CI and Approvals pass. 13 | - Bot Collaboration – Combine Kodiak with a dependency bot (dependabot, snyk, greenkeeper.io) to automate updating of dependencies 14 | 15 | And more! Checkout [the Kodiak docs](https://kodiakhq.com/docs/quickstart) to get started automating your GitHub PRs. 16 | 17 | ## Installation 18 | 19 | Kodiak is available through the GitHub Marketplace. 20 | 21 | [![install](https://3c7446e0-cd7f-4e98-a123-1875fcbf3182.s3.amazonaws.com/button-small.svg?v=123)](https://github.com/marketplace/kodiakhq) 22 | 23 | _If you'd rather run Kodiak yourself, check out the [self hosting page](https://kodiakhq.com/docs/self-hosting) in our docs._ 24 | 25 | View activity via the dashboard at . 26 | 27 | ## Example 28 | 29 | [![kodiak pull request flow](https://3c7446e0-cd7f-4e98-a123-1875fcbf3182.s3.amazonaws.com/marketplace+listing+image.svg)](https://github.com/marketplace/kodiakhq) 30 | 31 | Kodiak automatically updates branches, merges PRs and more! 32 | 33 | ## [Documentation](https://kodiakhq.com) 34 | 35 | Helpful Links: 36 | 37 | - [Getting Started](https://kodiakhq.com/docs/quickstart) 38 | - [Configuration Guide](https://kodiakhq.com/docs/config-reference) 39 | - [Why and How](https://kodiakhq.com/docs/why-and-how) 40 | - [Troubleshooting](https://kodiakhq.com/docs/troubleshooting) 41 | - [Help](https://kodiakhq.com/help) 42 | - [Prior Art / Alternatives](https://kodiakhq.com/docs/prior-art-and-alternatives) 43 | 44 | ## Sponsors 45 | 46 | ![Complex IT Aschaffenburg - GROW WITH US](https://user-images.githubusercontent.com/47448731/76313751-d3408b00-62d5-11ea-8f0f-a99e78b55a42.png) 47 | 48 | 49 | 51 | 52 | ## :money_with_wings: Sponsoring 53 | 54 | Using Kodiak for your commercial project? 55 | 56 | [Support Kodiak with GitHub Sponsors](https://github.com/sponsors/chdsbd) to help cover server costs and support development. 57 | 58 | ## Contributing 59 | 60 | Feel free to file feature requests, bug reports, help requests through the issue tracker. 61 | 62 | If you'd like to add a feature, fix a bug, update the docs, etc, take a peek at our [contributing guide](https://kodiakhq.com/docs/contributing). 63 | 64 | ## Project Layout 65 | 66 | This repository contains multiple services that make up Kodiak. The GitHub App which receives webhook events from GitHub and operates of pull requests is stored at `bot/`. The web API powering the Kodiak dashboard (WIP) is stored at `web_api/` and the Kodiak dashboard frontend (WIP) that talks to the web api is stored at `web_ui/`. 67 | -------------------------------------------------------------------------------- /assets/design.dot: -------------------------------------------------------------------------------- 1 | digraph Kodiak { 2 | 3 | "GitHub"[shape=doublecircle] 4 | 5 | "GitHub" -> "HTTP Webhook"[label="HTTP events", weight=0] 6 | 7 | 8 | subgraph cluster_kodiak { 9 | label = "Kodiak" 10 | 11 | "HTTP Webhook" -> Redis[label="write event to queue", weight=0] 12 | Redis[shape=cylinder] 13 | 14 | "Repo Queue Worker" -> Redis [label="pull off mergeable\n PRs and merge"] 15 | "Repo Queue Worker" -> "GitHub" 16 | "Async Worker" -> "GitHub" 17 | 18 | "Async Worker" -> Redis[label="write any mergeable PRs\nto per repo queue"] 19 | 20 | "Event Queue Worker" -> Redis[label="Left blocking pop \nfrom event queue"] 21 | "Event Queue Worker" -> "Async Worker"[style="dashed", label="create async task \nfor each event"] 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/assets/logo.png -------------------------------------------------------------------------------- /bot/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | .venv/* 4 | venv/* 5 | env/* 6 | .env/* 7 | -------------------------------------------------------------------------------- /bot/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | **/*.pyc 3 | **/__pycache__/ 4 | .venv/ 5 | Dockerfile 6 | *.pem 7 | *_cache/ 8 | assets/ 9 | .vscode/ 10 | pip-wheel-metadata/ 11 | *egg-info/ 12 | docs/ 13 | .github 14 | .circleci 15 | -------------------------------------------------------------------------------- /bot/.python-version: -------------------------------------------------------------------------------- 1 | 3.7.13 2 | -------------------------------------------------------------------------------- /bot/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["ms-python.python"] 3 | } -------------------------------------------------------------------------------- /bot/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.defaultInterpreterPath": ".venv/bin/python", 3 | "python.formatting.provider": "black", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": true 7 | }, 8 | "python.linting.mypyEnabled": true, 9 | "python.testing.pytestArgs": ["kodiak"], 10 | "python.testing.unittestEnabled": false, 11 | "python.testing.pytestEnabled": true, 12 | "python.linting.flake8Enabled": true, 13 | "python.linting.pylintEnabled": true 14 | } 15 | -------------------------------------------------------------------------------- /bot/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7@sha256:6eaf19442c358afc24834a6b17a3728a45c129de7703d8583392a138ecbdb092 2 | 3 | RUN set -ex && mkdir -p /var/app 4 | 5 | RUN apt-get update && apt-get install -y supervisor 6 | 7 | RUN mkdir -p /var/log/supervisor 8 | 9 | # use cryptography version for poetry that doesn't require Rust 10 | RUN python3 -m pip install cryptography===37.0.4 11 | RUN python3 -m pip install poetry===1.1.13 12 | 13 | RUN poetry config virtualenvs.in-project true 14 | 15 | COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf 16 | 17 | WORKDIR /var/app 18 | 19 | COPY pyproject.toml poetry.lock /var/app/ 20 | 21 | # install deps 22 | RUN poetry install 23 | 24 | COPY . /var/app 25 | 26 | # workaround for: https://github.com/sdispater/poetry/issues/1123 27 | RUN rm -rf /var/app/pip-wheel-metadata/ 28 | 29 | # install cli 30 | RUN poetry install 31 | 32 | CMD ["/usr/bin/supervisord"] 33 | -------------------------------------------------------------------------------- /bot/README.md: -------------------------------------------------------------------------------- 1 | # Kodiak GitHub App 2 | 3 | The Kodiak GitHub App receives webhook requests from GitHub and acts on GitHub pull requests. 4 | 5 | ## Dev 6 | 7 | The follow shows how to run commands for testing and development. For information on creating an GitHub App for testing, please see . 8 | 9 | ```shell 10 | # bot/ 11 | 12 | # install dependencies 13 | poetry config virtualenvs.in-project true 14 | poetry install 15 | 16 | # format and lint using black, isort, mypy, flake8, pylint 17 | s/lint 18 | 19 | # run tests using pytest 20 | # pytest flags can be passed like `s/test -s --pdb` 21 | s/test 22 | 23 | # create a .env file for local testing by copying the example and adding your 24 | # settings 25 | cp example.env .env 26 | 27 | # in a seperate terminal, start ngrok and configure your GitHub app settings to 28 | # route to the ngrok url 29 | ngrok http 3000 30 | 31 | # start development webserver. The Redis server specified in `.env` must be 32 | # running 33 | s/dev-ingest --reload 34 | # in another terminal start the workers 35 | s/dev-workers 36 | ``` 37 | 38 | If you have made any changes concerning the config, run the following command to update the schema: 39 | ```shell 40 | poetry run kodiak gen-conf-json-schema > kodiak/test/fixtures/config/config-schema.json 41 | ``` 42 | -------------------------------------------------------------------------------- /bot/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | kodiak: 4 | build: ./ 5 | environment: 6 | - "PORT=3000" 7 | - "REDISCLOUD_URL=http://redis:6379" 8 | - "GITHUB_PRIVATE_KEY_PATH=/var/app/kodiaktest.private-key.pem" 9 | volumes: 10 | - ./.env:/var/app/.env 11 | - ./kodiaktest.private-key.pem:/var/app/kodiaktest.private-key.pem 12 | ports: 13 | - 3000:3000 14 | networks: 15 | - redis-net 16 | depends_on: 17 | - redis 18 | 19 | redis: 20 | image: redis:5.0.7-alpine 21 | command: ["redis-server", "--appendonly", "yes"] 22 | hostname: redis 23 | networks: 24 | - redis-net 25 | volumes: 26 | - redis-data:/data 27 | 28 | networks: 29 | redis-net: 30 | 31 | volumes: 32 | redis-data: 33 | -------------------------------------------------------------------------------- /bot/example.env: -------------------------------------------------------------------------------- 1 | # a random, secure string set in your GitHub app settings 2 | SECRET_KEY='secret_key_from_github' 3 | 4 | # private key downloaded from GitHub for your app 5 | GITHUB_PRIVATE_KEY_PATH=your-app-name.some-date.private-key.pem 6 | 7 | # your GitHub app's id 8 | GITHUB_APP_ID=31500 9 | 10 | # your GitHub app's name 11 | GITHUB_APP_NAME=your-app-name 12 | 13 | # the url for your redis database 14 | REDISCLOUD_URL=redis://localhost:6379 15 | -------------------------------------------------------------------------------- /bot/kodiak/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/bot/kodiak/__init__.py -------------------------------------------------------------------------------- /bot/kodiak/assertions.py: -------------------------------------------------------------------------------- 1 | from typing import NoReturn 2 | 3 | 4 | def assert_never(value: NoReturn) -> NoReturn: 5 | """ 6 | Enable exhaustiveness checking when comparing against enums and unions 7 | of literals. 8 | """ 9 | raise Exception(f"expected never, got {value}") 10 | -------------------------------------------------------------------------------- /bot/kodiak/cli.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | from typing import Any, Dict, List 4 | 5 | import click 6 | import requests 7 | 8 | from kodiak import app_config as conf 9 | from kodiak.config import V1 10 | from kodiak.http import HttpClient 11 | from kodiak.queries import generate_jwt, get_token_for_install 12 | 13 | 14 | @click.group() 15 | def cli() -> None: 16 | pass 17 | 18 | 19 | @cli.command(help="create a JWT for testing GitHub API endpoints") 20 | def create_jwt() -> None: 21 | click.echo( 22 | generate_jwt(private_key=conf.PRIVATE_KEY, app_identifier=conf.GITHUB_APP_ID) 23 | ) 24 | 25 | 26 | @cli.command(help="generate the JSON schema for the .kodiak.toml") 27 | def gen_conf_json_schema() -> None: 28 | click.echo(V1.schema_json(indent=2)) 29 | 30 | 31 | @cli.command(help="list all installs for the Kodiak GitHub App") 32 | def list_installs() -> None: 33 | app_token = generate_jwt( 34 | private_key=conf.PRIVATE_KEY, app_identifier=conf.GITHUB_APP_ID 35 | ) 36 | results: List[Dict[str, Any]] = [] 37 | headers = dict( 38 | Accept="application/vnd.github.machine-man-preview+json", 39 | Authorization=f"Bearer {app_token}", 40 | ) 41 | url = conf.v3_url("/app/installations") 42 | while True: 43 | res = requests.get(url, headers=headers) 44 | res.raise_for_status() 45 | results += res.json() 46 | try: 47 | url = res.links["next"]["url"] 48 | except (KeyError, IndexError): 49 | break 50 | 51 | for r in results: 52 | try: 53 | install_url = r["account"]["html_url"] 54 | install_id = r["id"] 55 | click.echo(f"install:{install_id} for {install_url}") 56 | except (KeyError, IndexError): 57 | pass 58 | 59 | 60 | @cli.command(help="fetches the OAuth token for a given install id") 61 | @click.argument("install_id") 62 | def token_for_install(install_id: str) -> None: 63 | """ 64 | outputs the OAuth token for a given installation id. 65 | This is useful to help debug installation problems 66 | """ 67 | 68 | async def get_token() -> str: 69 | async with HttpClient() as http: 70 | return await get_token_for_install(session=http, installation_id=install_id) 71 | 72 | token = asyncio.run(get_token()) 73 | click.echo(token) 74 | 75 | 76 | @cli.command(help="prints out kodiak's view of a .kodiak.toml") 77 | @click.argument("config_path", type=click.Path(exists=True)) 78 | def validate_config(config_path: str) -> None: 79 | """ 80 | parse and output the json representation of a Kodiak config 81 | """ 82 | cfg_text = Path(config_path).read_text() 83 | cfg_file = V1.parse_toml(cfg_text) 84 | assert isinstance(cfg_file, V1) 85 | click.echo(cfg_file.json(indent=2)) 86 | 87 | 88 | @cli.command(help="listen for messages and trigger pull request refreshes") 89 | def refresh_pull_requests() -> None: 90 | """ 91 | Listen on a Redis list for messages triggering pull request reevaluations. 92 | """ 93 | from kodiak.refresh_pull_requests import main 94 | 95 | main() 96 | -------------------------------------------------------------------------------- /bot/kodiak/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.fixture(autouse=True) 5 | def configure_structlog() -> None: 6 | """ 7 | Configures cleanly structlog for each test method. 8 | https://github.com/hynek/structlog/issues/76#issuecomment-240373958 9 | """ 10 | import structlog 11 | 12 | structlog.reset_defaults() 13 | structlog.configure( 14 | processors=[ 15 | structlog.processors.StackInfoRenderer(), 16 | structlog.processors.format_exc_info, 17 | structlog.processors.KeyValueRenderer(), 18 | ], 19 | wrapper_class=structlog.stdlib.BoundLogger, 20 | context_class=dict, 21 | logger_factory=structlog.stdlib.LoggerFactory(), 22 | cache_logger_on_first_use=False, 23 | ) 24 | -------------------------------------------------------------------------------- /bot/kodiak/entrypoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/bot/kodiak/entrypoints/__init__.py -------------------------------------------------------------------------------- /bot/kodiak/errors.py: -------------------------------------------------------------------------------- 1 | class RetryForSkippableChecks(Exception): 2 | pass 3 | 4 | 5 | class PollForever(Exception): 6 | pass 7 | 8 | 9 | class ApiCallException(Exception): 10 | def __init__(self, method: str, http_status_code: int, response: bytes) -> None: 11 | self.method = method 12 | self.status_code = http_status_code 13 | self.response = response 14 | 15 | 16 | class GitHubApiInternalServerError(Exception): 17 | pass 18 | -------------------------------------------------------------------------------- /bot/kodiak/events/README.md: -------------------------------------------------------------------------------- 1 | # kodiak.events 2 | 3 | Here we store the minimal schema definitions we need to parse webhook payloads. To reduce the chance of parsing errors, we only parse what we need. 4 | 5 | Some schema structures like `Repository` are duplicated between events. This ensures we only parse what we need for an individual event and out of concern that information for a `Repository` in one payload is not the same as another. 6 | -------------------------------------------------------------------------------- /bot/kodiak/events/__init__.py: -------------------------------------------------------------------------------- 1 | from kodiak.events.check_run import CheckRunEvent # noqa: F401 2 | from kodiak.events.pull_request import PullRequestEvent # noqa: F401 3 | from kodiak.events.pull_request_review import PullRequestReviewEvent # noqa: F401 4 | from kodiak.events.pull_request_review_thread import ( # noqa: F401 5 | PullRequestReviewThreadEvent, 6 | ) 7 | from kodiak.events.push import PushEvent # noqa: F401 8 | from kodiak.events.status import StatusEvent # noqa: F401 9 | -------------------------------------------------------------------------------- /bot/kodiak/events/base.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | 4 | class Installation(pydantic.BaseModel): 5 | id: int 6 | 7 | 8 | class GithubEvent(pydantic.BaseModel): 9 | installation: Installation 10 | -------------------------------------------------------------------------------- /bot/kodiak/events/check_run.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import pydantic 4 | 5 | from kodiak.events.base import GithubEvent 6 | 7 | 8 | class PullRequestRepository(pydantic.BaseModel): 9 | id: int 10 | 11 | 12 | class Ref(pydantic.BaseModel): 13 | ref: str 14 | repo: PullRequestRepository 15 | 16 | 17 | class PullRequest(pydantic.BaseModel): 18 | number: int 19 | base: Ref 20 | 21 | 22 | class CheckRun(pydantic.BaseModel): 23 | name: str 24 | pull_requests: List[PullRequest] 25 | 26 | 27 | class Owner(pydantic.BaseModel): 28 | login: str 29 | 30 | 31 | class Repository(pydantic.BaseModel): 32 | id: int 33 | name: str 34 | owner: Owner 35 | 36 | 37 | class CheckRunEvent(GithubEvent): 38 | """ 39 | https://developer.github.com/v3/activity/events/types/#checkrunevent 40 | """ 41 | 42 | check_run: CheckRun 43 | repository: Repository 44 | -------------------------------------------------------------------------------- /bot/kodiak/events/pull_request.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from kodiak.events.base import GithubEvent 4 | 5 | 6 | class Owner(pydantic.BaseModel): 7 | login: str 8 | 9 | 10 | class Repository(pydantic.BaseModel): 11 | name: str 12 | owner: Owner 13 | 14 | 15 | class Ref(pydantic.BaseModel): 16 | ref: str 17 | 18 | 19 | class PullRequest(pydantic.BaseModel): 20 | base: Ref 21 | 22 | 23 | class PullRequestEvent(GithubEvent): 24 | """ 25 | https://developer.github.com/v3/activity/events/types/#pullrequestevent 26 | """ 27 | 28 | number: int 29 | pull_request: PullRequest 30 | repository: Repository 31 | -------------------------------------------------------------------------------- /bot/kodiak/events/pull_request_review.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from kodiak.events.base import GithubEvent 4 | 5 | 6 | class Ref(pydantic.BaseModel): 7 | ref: str 8 | 9 | 10 | class PullRequest(pydantic.BaseModel): 11 | number: int 12 | base: Ref 13 | 14 | 15 | class Owner(pydantic.BaseModel): 16 | login: str 17 | 18 | 19 | class Repository(pydantic.BaseModel): 20 | name: str 21 | owner: Owner 22 | 23 | 24 | class PullRequestReviewEvent(GithubEvent): 25 | """ 26 | https://developer.github.com/v3/activity/events/types/#pullrequestreviewevent 27 | """ 28 | 29 | pull_request: PullRequest 30 | repository: Repository 31 | -------------------------------------------------------------------------------- /bot/kodiak/events/pull_request_review_thread.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from kodiak.events.base import GithubEvent 4 | 5 | 6 | class Ref(pydantic.BaseModel): 7 | ref: str 8 | 9 | 10 | class PullRequest(pydantic.BaseModel): 11 | number: int 12 | base: Ref 13 | 14 | 15 | class Owner(pydantic.BaseModel): 16 | login: str 17 | 18 | 19 | class Repository(pydantic.BaseModel): 20 | name: str 21 | owner: Owner 22 | 23 | 24 | class PullRequestReviewThreadEvent(GithubEvent): 25 | """ 26 | This event is currently undocumented as of 2021-07-20. 27 | """ 28 | 29 | pull_request: PullRequest 30 | repository: Repository 31 | -------------------------------------------------------------------------------- /bot/kodiak/events/push.py: -------------------------------------------------------------------------------- 1 | import pydantic 2 | 3 | from kodiak.events.base import GithubEvent 4 | 5 | 6 | class Owner(pydantic.BaseModel): 7 | login: str 8 | 9 | 10 | class Repository(pydantic.BaseModel): 11 | name: str 12 | owner: Owner 13 | 14 | 15 | class PushEvent(GithubEvent): 16 | """ 17 | https://developer.github.com/v3/activity/events/types/#pushevent 18 | """ 19 | 20 | ref: str 21 | repository: Repository 22 | -------------------------------------------------------------------------------- /bot/kodiak/events/status.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | import pydantic 4 | 5 | from kodiak.events.base import GithubEvent 6 | 7 | 8 | class Commit(pydantic.BaseModel): 9 | sha: Optional[str] 10 | 11 | 12 | class Branch(pydantic.BaseModel): 13 | name: str 14 | commit: Commit 15 | 16 | 17 | class Owner(pydantic.BaseModel): 18 | login: str 19 | 20 | 21 | class Repository(pydantic.BaseModel): 22 | name: str 23 | owner: Owner 24 | 25 | 26 | class StatusEvent(GithubEvent): 27 | """ 28 | https://developer.github.com/v3/activity/events/types/#statusevent 29 | """ 30 | 31 | id: int 32 | sha: str 33 | branches: List[Branch] 34 | repository: Repository 35 | -------------------------------------------------------------------------------- /bot/kodiak/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ssl 4 | 5 | from httpx import ( # noqa: I251 6 | AsyncClient, 7 | HTTPError, 8 | HTTPStatusError, 9 | Request, 10 | Response, 11 | ) 12 | from httpx._config import DEFAULT_TIMEOUT_CONFIG # noqa: I251 13 | from httpx._types import TimeoutTypes # noqa: I251 14 | 15 | __all__ = ["Response", "Request", "HTTPError", "HttpClient", "HTTPStatusError"] 16 | 17 | # NOTE: this has a cost to create so we may want to set this lazily on the first HttpClient creation 18 | context = ssl.create_default_context() 19 | 20 | 21 | class HttpClient(AsyncClient): 22 | """ 23 | HTTP Client with the SSL config cached at the module level to avoid perf issues. 24 | see: https://github.com/encode/httpx/issues/838 25 | """ 26 | 27 | def __init__( 28 | self, 29 | *, 30 | timeout: TimeoutTypes = DEFAULT_TIMEOUT_CONFIG, 31 | ): 32 | 33 | super().__init__( 34 | verify=context, 35 | timeout=timeout, 36 | ) 37 | -------------------------------------------------------------------------------- /bot/kodiak/messages.py: -------------------------------------------------------------------------------- 1 | from typing import Sequence, Union 2 | 3 | import markupsafe 4 | import pydantic 5 | import toml 6 | from typing_extensions import Protocol 7 | 8 | FOOTER = """ 9 | If you need help, you can open a GitHub issue, check the docs, or reach us privately at support@kodiakhq.com. 10 | 11 | [docs](https://kodiakhq.com/docs/troubleshooting) | [dashboard](https://app.kodiakhq.com) | [support](https://kodiakhq.com/help) 12 | 13 | """ 14 | 15 | 16 | def format(msg: str) -> str: 17 | return msg + "\n" + FOOTER 18 | 19 | 20 | def get_markdown_for_config( 21 | error: Union[pydantic.ValidationError, toml.TomlDecodeError], 22 | config_str: str, 23 | git_path: str, 24 | ) -> str: 25 | config_escaped = markupsafe.escape(config_str) 26 | if isinstance(error, pydantic.ValidationError): 27 | error_escaped = f"# pretty \n{error}\n\n\n# json \n{error.json()}" 28 | else: 29 | error_escaped = markupsafe.escape(repr(error)) 30 | line_count = config_str.count("\n") + 1 31 | return format( 32 | f"""\ 33 | You have an invalid Kodiak configuration file. 34 | 35 | ## configuration file 36 | > config_file_expression: {git_path} 37 | > line count: {line_count} 38 | 39 |
 40 | {config_escaped}
 41 | 
42 | 43 | ## configuration error message 44 |
 45 | {error_escaped}
 46 | 
47 | 48 | ## notes 49 | - Check the Kodiak docs for setup information at https://kodiakhq.com/docs/quickstart. 50 | - A configuration reference is available at https://kodiakhq.com/docs/config-reference. 51 | - Full examples are available at https://kodiakhq.com/docs/recipes 52 | """ 53 | ) 54 | 55 | 56 | def get_markdown_for_paywall() -> str: 57 | return format( 58 | """\ 59 | You can start a 30 day trial or update your subscription on the Kodiak dashboard at https://app.kodiakhq.com. 60 | 61 | Kodiak is free to use on public repositories, but requires a subscription to use with private repositories. 62 | 63 | See the [Kodiak docs](https://kodiakhq.com/docs/billing) for more information about free trials and subscriptions. 64 | """ 65 | ) 66 | 67 | 68 | def get_markdown_for_push_allowance_error(*, branch_name: str) -> str: 69 | return format( 70 | f"""\ 71 | Your branch protection setting for `{branch_name}` has "Restrict who can push to matching branches" enabled. You must allow Kodiak to push to this branch for Kodiak to merge pull requests. 72 | 73 | See the Kodiak troubleshooting docs for more information: https://kodiakhq.com/docs/troubleshooting#restricting-pushes 74 | """ 75 | ) 76 | 77 | 78 | class APICallRetry(Protocol): 79 | @property 80 | def api_name(self) -> str: 81 | ... 82 | 83 | @property 84 | def http_status(self) -> str: 85 | ... 86 | 87 | @property 88 | def response_body(self) -> str: 89 | ... 90 | 91 | 92 | def get_markdown_for_api_call_errors(*, errors: Sequence[APICallRetry]) -> str: 93 | formatted_errors = "\n".join( 94 | f"- API call {error.api_name!r} failed with HTTP status {error.http_status!r} and response: {error.response_body!r}" 95 | for error in errors 96 | ) 97 | return format( 98 | f"""\ 99 | Errors encountered when contacting GitHub API. 100 | 101 | {formatted_errors} 102 | """ 103 | ) 104 | -------------------------------------------------------------------------------- /bot/kodiak/queries/commits.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, Optional 2 | 3 | import pydantic 4 | import structlog 5 | 6 | logger = structlog.get_logger() 7 | 8 | 9 | class CommitConnection(pydantic.BaseModel): 10 | totalCount: int 11 | 12 | 13 | class User(pydantic.BaseModel): 14 | databaseId: Optional[int] 15 | login: str 16 | name: Optional[str] 17 | type: str 18 | 19 | def __hash__(self) -> int: 20 | # defining a hash method allows us to deduplicate CommitAuthors easily. 21 | return hash(self.databaseId) + hash(self.login) + hash(self.name) 22 | 23 | 24 | class GitActor(pydantic.BaseModel): 25 | user: Optional[User] 26 | 27 | 28 | class Commit(pydantic.BaseModel): 29 | parents: CommitConnection 30 | author: Optional[GitActor] 31 | 32 | 33 | class PullRequestCommit(pydantic.BaseModel): 34 | commit: Commit 35 | 36 | 37 | class PullRequestCommitConnection(pydantic.BaseModel): 38 | nodes: Optional[List[PullRequestCommit]] 39 | 40 | 41 | class PullRequest(pydantic.BaseModel): 42 | commitHistory: PullRequestCommitConnection 43 | 44 | 45 | def get_commits(*, pr: Dict[str, Any]) -> List[Commit]: 46 | """ 47 | Extract the commit authors from the pull request commits. 48 | """ 49 | # we use a dict as an ordered set. 50 | try: 51 | pull_request = PullRequest.parse_obj(pr) 52 | except pydantic.ValidationError: 53 | logger.exception("problem parsing commit authors") 54 | return [] 55 | nodes = pull_request.commitHistory.nodes 56 | if not nodes: 57 | return [] 58 | return [node.commit for node in nodes] 59 | -------------------------------------------------------------------------------- /bot/kodiak/redis_client.py: -------------------------------------------------------------------------------- 1 | import redis.asyncio as redis 2 | 3 | import kodiak.app_config as conf 4 | 5 | 6 | def create_connection() -> "redis.Redis[bytes]": 7 | redis_db = 0 8 | try: 9 | redis_db = int(conf.REDIS_URL.database) 10 | except ValueError: 11 | pass 12 | 13 | return redis.Redis( 14 | host=conf.REDIS_URL.hostname or "localhost", 15 | port=conf.REDIS_URL.port or 6379, 16 | username=conf.REDIS_URL.username, 17 | password=conf.REDIS_URL.password, 18 | ssl=conf.REDIS_URL.scheme == "rediss", 19 | db=redis_db, 20 | socket_keepalive=True, 21 | socket_timeout=conf.REDIS_SOCKET_TIMEOUT_SEC, 22 | socket_connect_timeout=conf.REDIS_SOCKET_CONNECT_TIMEOUT_SEC, 23 | health_check_interval=True, 24 | ) 25 | 26 | 27 | redis_bot = create_connection() 28 | redis_web_api = create_connection() 29 | -------------------------------------------------------------------------------- /bot/kodiak/schemas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Dict 4 | 5 | import pydantic 6 | 7 | 8 | class RawWebhookEvent(pydantic.BaseModel): 9 | event_name: str 10 | payload: Dict[str, Any] 11 | -------------------------------------------------------------------------------- /bot/kodiak/test/fixtures/config/v1-default.toml: -------------------------------------------------------------------------------- 1 | # configuration with all defaults set 2 | version = 1 # required 3 | 4 | [merge] 5 | automerge_label = "automerge" 6 | require_automerge_label = true 7 | # this setting is for snapshot testing purposes. This gets replaced with the 8 | # `WIP:.*` default regex in evaluation.py 9 | blacklist_title_regex = ":::|||kodiak|||internal|||reserved|||:::" 10 | blacklist_labels = [] 11 | delete_branch_on_merge = false 12 | block_on_reviews_requested = false 13 | notify_on_conflict = true 14 | optimistic_updates = true 15 | dont_wait_on_status_checks = [] 16 | update_branch_immediately = false 17 | prioritize_ready_to_merge = false 18 | do_not_merge = false 19 | 20 | [merge.message] 21 | title = "github_default" 22 | body = "github_default" 23 | include_pr_number = true 24 | body_type = "markdown" 25 | strip_html_comments = false 26 | 27 | [update] 28 | always = false 29 | require_automerge_label = true 30 | 31 | [approve] 32 | auto_approve_usernames = [] 33 | auto_approve_labels = [] 34 | -------------------------------------------------------------------------------- /bot/kodiak/test/fixtures/config/v1-opposite.1.toml: -------------------------------------------------------------------------------- 1 | # configuration with all settings changed from defaults 2 | version = 1 # required 3 | app_id = 12345 # default: None 4 | 5 | [merge] 6 | automerge_label = "mergeit!" # default: "automerge" 7 | require_automerge_label = false # default: true 8 | blacklist_title_regex = "" # default: "^WIP:.*" 9 | blacklist_labels = ["wip", "block-merge"] # default: [] 10 | method = "squash" # default: first valid merge method in list `"merge"`, `"squash"`, `"rebase"` 11 | delete_branch_on_merge = true # default: false 12 | block_on_reviews_requested = true # default: false 13 | notify_on_conflict = false # default: true 14 | optimistic_updates = false # default: true 15 | dont_wait_on_status_checks = ["ci/circleci: deploy"] # default: [] 16 | update_branch_immediately = true 17 | prioritize_ready_to_merge = true 18 | do_not_merge = true 19 | 20 | [merge.message] 21 | title = "pull_request_title" # default: "github_default" 22 | body = "pull_request_body" # default: "github_default" 23 | include_pr_number = false # default: true 24 | body_type = "plain_text" # default: "markdown" 25 | strip_html_comments = true # default: false 26 | 27 | [update] 28 | always = true 29 | require_automerge_label = false 30 | 31 | [approve] 32 | auto_approve_usernames = ["dependabot"] # default: [] 33 | auto_approve_labels = ["autoapprove"] # default: [] 34 | -------------------------------------------------------------------------------- /bot/kodiak/test/fixtures/config/v1-opposite.2.toml: -------------------------------------------------------------------------------- 1 | # configuration with all settings changed from defaults 2 | version = 1 # required 3 | app_id = 12345 # default: None 4 | 5 | [merge] 6 | automerge_label = "mergeit!" # default: "automerge" 7 | require_automerge_label = false # default: true 8 | blacklist_title_regex = "" # default: "^WIP:.*" 9 | blacklist_labels = ["wip", "block-merge"] # default: [] 10 | method = "squash" # default: first valid merge method in list `"merge"`, `"squash"`, `"rebase"` 11 | delete_branch_on_merge = true # default: false 12 | block_on_reviews_requested = true # default: false 13 | notify_on_conflict = false # default: true 14 | optimistic_updates = false # default: true 15 | dont_wait_on_status_checks = ["ci/circleci: deploy"] # default: [] 16 | update_branch_immediately = true 17 | prioritize_ready_to_merge = true 18 | do_not_merge = true 19 | 20 | [merge.message] 21 | title = "pull_request_title" # default: "github_default" 22 | body = "empty" # default: "github_default" 23 | include_pr_number = false # default: true 24 | body_type = "plain_text" # default: "markdown" 25 | strip_html_comments = true # default: false 26 | 27 | [update] 28 | always = true 29 | require_automerge_label = false 30 | 31 | [approve] 32 | auto_approve_usernames = ["dependabot"] # default: [] 33 | auto_approve_labels = ["autoapprove"] # default: [] 34 | -------------------------------------------------------------------------------- /bot/kodiak/test/fixtures/config_utils/pydantic-error.md: -------------------------------------------------------------------------------- 1 | You have an invalid Kodiak configuration file. 2 | 3 | ## configuration file 4 | > config_file_expression: master:.kodiak.toml 5 | > line count: 1 6 | 7 |
 8 | version = 12
 9 | 
10 | 11 | ## configuration error message 12 |
13 | # pretty 
14 | 1 validation error for V1
15 | version
16 |   Version must be `1` (type=value_error.invalidversion)
17 | 
18 | 
19 | # json 
20 | [
21 |   {
22 |     "loc": [
23 |       "version"
24 |     ],
25 |     "msg": "Version must be `1`",
26 |     "type": "value_error.invalidversion"
27 |   }
28 | ]
29 | 
30 | 31 | ## notes 32 | - Check the Kodiak docs for setup information at https://kodiakhq.com/docs/quickstart. 33 | - A configuration reference is available at https://kodiakhq.com/docs/config-reference. 34 | - Full examples are available at https://kodiakhq.com/docs/recipes 35 | 36 | 37 | If you need help, you can open a GitHub issue, check the docs, or reach us privately at support@kodiakhq.com. 38 | 39 | [docs](https://kodiakhq.com/docs/troubleshooting) | [dashboard](https://app.kodiakhq.com) | [support](https://kodiakhq.com/help) 40 | 41 | -------------------------------------------------------------------------------- /bot/kodiak/test/fixtures/config_utils/toml-error.md: -------------------------------------------------------------------------------- 1 | You have an invalid Kodiak configuration file. 2 | 3 | ## configuration file 4 | > config_file_expression: master:.kodiak.toml 5 | > line count: 1 6 | 7 |
 8 | [[[ version = 12
 9 | 
10 | 11 | ## configuration error message 12 |
13 | TomlDecodeError('Key group not on a line by itself. (line 1 column 1 char 0)')
14 | 
15 | 16 | ## notes 17 | - Check the Kodiak docs for setup information at https://kodiakhq.com/docs/quickstart. 18 | - A configuration reference is available at https://kodiakhq.com/docs/config-reference. 19 | - Full examples are available at https://kodiakhq.com/docs/recipes 20 | 21 | 22 | If you need help, you can open a GitHub issue, check the docs, or reach us privately at support@kodiakhq.com. 23 | 24 | [docs](https://kodiakhq.com/docs/troubleshooting) | [dashboard](https://app.kodiakhq.com) | [support](https://kodiakhq.com/help) 25 | 26 | -------------------------------------------------------------------------------- /bot/kodiak/test_config_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from kodiak.config import V1 4 | from kodiak.messages import get_markdown_for_config 5 | 6 | 7 | def load_config_fixture(fixture_name: str) -> Path: 8 | return Path(__file__).parent / "test" / "fixtures" / "config_utils" / fixture_name 9 | 10 | 11 | def test_get_markdown_for_config_pydantic_error() -> None: 12 | config = "version = 12" 13 | error = V1.parse_toml(config) 14 | assert not isinstance(error, V1) 15 | markdown = get_markdown_for_config( 16 | error, config_str=config, git_path="master:.kodiak.toml" 17 | ) 18 | assert markdown == load_config_fixture("pydantic-error.md").read_text() 19 | 20 | 21 | def test_get_markdown_for_config_toml_error() -> None: 22 | config = "[[[ version = 12" 23 | error = V1.parse_toml(config) 24 | assert not isinstance(error, V1) 25 | markdown = get_markdown_for_config( 26 | error, config_str=config, git_path="master:.kodiak.toml" 27 | ) 28 | assert markdown == load_config_fixture("toml-error.md").read_text() 29 | -------------------------------------------------------------------------------- /bot/kodiak/test_event_handlers.py: -------------------------------------------------------------------------------- 1 | from kodiak.queue import get_branch_name 2 | 3 | 4 | def test_get_branch_name() -> None: 5 | assert get_branch_name("refs/heads/master") == "master" 6 | assert ( 7 | get_branch_name("refs/heads/master/refs/heads/123") == "master/refs/heads/123" 8 | ) 9 | assert get_branch_name("refs/tags/v0.1.0") is None 10 | -------------------------------------------------------------------------------- /bot/kodiak/test_events.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from pydantic import BaseModel 5 | 6 | from kodiak import events 7 | 8 | # A mapping of all events to their corresponding fixtures. Any new event must 9 | # register themselves here for testing. 10 | MAPPING = ( 11 | ("check_run", events.CheckRunEvent), 12 | ("pull_request", events.PullRequestEvent), 13 | ("pull_request_review", events.PullRequestReviewEvent), 14 | ("pull_request_review_thread", events.PullRequestReviewThreadEvent), 15 | ("status", events.StatusEvent), 16 | ("push", events.PushEvent), 17 | ) 18 | 19 | 20 | @pytest.mark.parametrize("event_name, schema", MAPPING) 21 | def test_event_parsing(event_name: str, schema: BaseModel) -> None: 22 | for fixture_path in ( 23 | Path(__file__).parent / "test" / "fixtures" / "events" / event_name 24 | ).rglob("*.json"): 25 | schema.parse_file(fixture_path) 26 | -------------------------------------------------------------------------------- /bot/kodiak/test_queue.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from kodiak.queue import installation_id_from_queue 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "queue_name, expected_installation_id", 8 | ( 9 | ("merge_queue:11256551.sbdchd/squawk/main.test.foo", "11256551"), 10 | ("merge_queue:11256551.sbdchd/squawk", "11256551"), 11 | ("merge_queue:11256551.sbdchd/squawk:repo/main:test.branch", "11256551"), 12 | ("webhook:11256551", "11256551"), 13 | ("", ""), 14 | ), 15 | ) 16 | def test_installation_id_from_queue( 17 | queue_name: str, expected_installation_id: str 18 | ) -> None: 19 | """ 20 | We should gracefully parse an installation id from the queue name 21 | """ 22 | assert installation_id_from_queue(queue_name) == expected_installation_id 23 | -------------------------------------------------------------------------------- /bot/kodiak/test_text.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from kodiak.text import strip_html_comments_from_markdown 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "original,stripped", 8 | [ 9 | ( 10 | """\ 11 | Non dolor velit vel quia mollitia. Placeat cumque a deleniti possimus. 12 | 13 | Totam dolor [exercitationem laborum](https://numquam.com) 14 | 15 | 22 | """, 23 | """\ 24 | Non dolor velit vel quia mollitia. Placeat cumque a deleniti possimus. 25 | 26 | Totam dolor [exercitationem laborum](https://numquam.com) 27 | 28 | 29 | """, 30 | ), 31 | ( 32 | 'Non dolor velit vel quia mollitia.\r\n\r\nVoluptates nulla tempora.\r\n\r\n', 33 | "Non dolor velit vel quia mollitia.\n\nVoluptates nulla tempora.\n\n", 34 | ), 35 | ("hello world", "hello world"), 36 | ( 37 | "hello

hello

world", 38 | "hello

hello

world", 39 | ), 40 | ( 41 | "hello

hello

world", 42 | "hello

hello

world", 43 | ), 44 | ( 45 | """\ 46 | this is an example comment message with a comment from a PR template 47 | 48 | 55 | """, 56 | """\ 57 | this is an example comment message with a comment from a PR template 58 | 59 | 60 | """, 61 | ), 62 | ("🏷️", "🏷️"), 63 | ], 64 | ) 65 | def test_strip_html_comments_from_markdown(original: str, stripped: str) -> None: 66 | assert strip_html_comments_from_markdown(original) == stripped 67 | -------------------------------------------------------------------------------- /bot/kodiak/test_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | from typing import TypeVar 4 | 5 | T = TypeVar("T") 6 | 7 | # some change happened in Python 3.8 to eliminate the need for wrapping mock 8 | # results. 9 | if sys.version_info < (3, 8): 10 | 11 | def wrap_future(x: T) -> "asyncio.Future[T]": 12 | fut: "asyncio.Future[T]" = asyncio.Future() 13 | fut.set_result(x) 14 | return fut 15 | 16 | 17 | else: 18 | 19 | def wrap_future(x: T) -> "T": 20 | return x 21 | -------------------------------------------------------------------------------- /bot/kodiak/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/bot/kodiak/tests/__init__.py -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/bot/kodiak/tests/dependencies/__init__.py -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/pull_requests/update-major-github_action.txt: -------------------------------------------------------------------------------- 1 | chore(deps): update codecov/codecov-action action to v2 2 | [![WhiteSource Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) 3 | 4 | This PR contains the following updates: 5 | 6 | | Package | Type | Update | Change | 7 | |---|---|---|---| 8 | | [codecov/codecov-action](https://togithub.com/codecov/codecov-action) | action | major | `v1` -> `v2` | 9 | 10 | --- 11 | 12 | ### Release Notes 13 | 14 |
15 | codecov/codecov-action 16 | 17 | ### [`v2`](https://togithub.com/codecov/codecov-action/compare/v1...v2) 18 | 19 | [Compare Source](https://togithub.com/codecov/codecov-action/compare/v1...v2) 20 | 21 |
22 | 23 | --- 24 | 25 | ### Configuration 26 | 27 | 📅 **Schedule**: "before 3am on Monday" (UTC). 28 | 29 | 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. 30 | 31 | ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 32 | 33 | 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. 34 | 35 | --- 36 | 37 | - [ ] If you want to rebase/retry this PR, check this box. 38 | 39 | --- 40 | 41 | This PR has been generated by [WhiteSource Renovate](https://renovate.whitesourcesoftware.com). View repository job log [here](https://app.renovatebot.com/dashboard#github/netlify/cli). 42 | 43 | source: https://github.com/netlify/cli/pull/2997 44 | -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/pull_requests/update-minor-single_package.txt: -------------------------------------------------------------------------------- 1 | fix(deps): update dependency @netlify/zip-it-and-ship-it to v4.15.0 2 | [![WhiteSource Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) 3 | 4 | This PR contains the following updates: 5 | 6 | | Package | Change | Age | Adoption | Passing | Confidence | 7 | |---|---|---|---|---|---| 8 | | [@netlify/zip-it-and-ship-it](https://togithub.com/netlify/zip-it-and-ship-it) | [`4.14.0` -> `4.15.0`](https://renovatebot.com/diffs/npm/@netlify%2fzip-it-and-ship-it/4.14.0/4.15.0) | [![age](https://badges.renovateapi.com/packages/npm/@netlify%2fzip-it-and-ship-it/4.15.0/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/@netlify%2fzip-it-and-ship-it/4.15.0/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/@netlify%2fzip-it-and-ship-it/4.15.0/compatibility-slim/4.14.0)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/@netlify%2fzip-it-and-ship-it/4.15.0/confidence-slim/4.14.0)](https://docs.renovatebot.com/merge-confidence/) | 9 | 10 | --- 11 | 12 | ### Release Notes 13 | 14 |
15 | netlify/zip-it-and-ship-it 16 | 17 | ### [`v4.15.0`](https://togithub.com/netlify/zip-it-and-ship-it/blob/master/CHANGELOG.md#​4150-httpswwwgithubcomnetlifyzip-it-and-ship-itcomparev4140v4150-2021-07-26) 18 | 19 | [Compare Source](https://togithub.com/netlify/zip-it-and-ship-it/compare/v4.14.0...v4.15.0) 20 | 21 | ##### Features 22 | 23 | - build Rust functions from source ([#​587](https://www.github.com/netlify/zip-it-and-ship-it/issues/587)) ([5d48d64](https://www.github.com/netlify/zip-it-and-ship-it/commit/5d48d64bea0d963efa9dbe64925baef24002a9a4)) 24 | 25 |
26 | 27 | --- 28 | 29 | ### Configuration 30 | 31 | 📅 **Schedule**: At any time (no schedule defined). 32 | 33 | 🚦 **Automerge**: Enabled. 34 | 35 | ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 36 | 37 | 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. 38 | 39 | --- 40 | 41 | - [ ] If you want to rebase/retry this PR, check this box. 42 | 43 | --- 44 | 45 | This PR has been generated by [WhiteSource Renovate](https://renovate.whitesourcesoftware.com). View repository job log [here](https://app.renovatebot.com/dashboard#github/netlify/cli). 46 | 47 | source: https://github.com/netlify/cli/pull/3011 48 | -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/pull_requests/update-patch-dependabot_single.txt: -------------------------------------------------------------------------------- 1 | Bump lodash from 4.17.15 to 4.17.19 2 | Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19. 3 | - [Release notes](https://github.com/lodash/lodash/releases) 4 | - [Commits](lodash/lodash@4.17.15...4.17.19) 5 | 6 | Signed-off-by: dependabot[bot] 7 | -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/pull_requests/update-patch-lock_file_maintenance.txt: -------------------------------------------------------------------------------- 1 | chore(deps): lock file maintenance 2 | [![WhiteSource Renovate](https://app.renovatebot.com/images/banner.svg)](https://renovatebot.com) 3 | 4 | This PR contains the following updates: 5 | 6 | | Update | Change | 7 | |---|---| 8 | | lockFileMaintenance | All locks refreshed | 9 | 10 | 🔧 This Pull Request updates lock files to use the latest dependency versions. 11 | 12 | --- 13 | 14 | ### Configuration 15 | 16 | 📅 **Schedule**: "before 5am on monday" (UTC). 17 | 18 | 🚦 **Automerge**: Enabled. 19 | 20 | ♻ **Rebasing**: Renovate will not automatically rebase this PR, because other commits have been found. 21 | 22 | 👻 **Immortal**: This PR will be recreated if closed unmerged. Get [config help](https://togithub.com/renovatebot/renovate/discussions) if that's undesired. 23 | 24 | --- 25 | 26 | - [ ] If you want to rebase/retry this PR, check this box. 27 | 28 | --- 29 | 30 | This PR has been generated by [WhiteSource Renovate](https://renovate.whitesourcesoftware.com). View repository job log [here](https://app.renovatebot.com/dashboard#github/netlify/cli). 31 | 32 | source: https://github.com/netlify/cli/pull/2999 33 | -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/test_dependabot.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for dependabot PRs. 3 | """ 4 | from __future__ import annotations 5 | 6 | import pytest 7 | 8 | from kodiak.dependencies import ( 9 | _compare_versions, 10 | _extract_versions, 11 | dep_version_from_title, 12 | dep_versions_from_pr, 13 | ) 14 | from kodiak.tests.dependencies.test_dependencies import FakePR 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "title,version,upgrade", 19 | [ 20 | ( 21 | "Bump pip from 20.2.4 to 20.3 in /.github/workflows", 22 | ("20.2.4", "20.3"), 23 | "minor", 24 | ), 25 | ("Bump lodash from 4.17.15 to 4.17.19", ("4.17.15", "4.17.19"), "patch"), 26 | ("Update tokio requirement from 0.2 to 0.3", ("0.2", "0.3"), "minor"), 27 | ( 28 | "Bump jackson-databind from 2.9.10.1 to 2.10.0.pr1 in /LiveIngest/LiveEventWithDVR", 29 | ("2.9.10.1", "2.10.0.pr1"), 30 | "minor", 31 | ), 32 | ( 33 | "Bump commons-collections from 4.0 to 4.1 in /eosio-explorer/Quantum", 34 | ("4.0", "4.1"), 35 | "minor", 36 | ), 37 | ( 38 | "[Snyk] Security upgrade engine.io from 3.5.0 to 4.0.0", 39 | ("3.5.0", "4.0.0"), 40 | "major", 41 | ), 42 | ("Bump lodash", None, None), 43 | ("Bump lodash to 4.17.19", None, None), 44 | ("Bump lodash from 4.17.15 to", None, None), 45 | ], 46 | ) 47 | def test_extract_versions( 48 | title: str, version: tuple[str, str] | None, upgrade: str | None 49 | ) -> None: 50 | assert _extract_versions(title) == version 51 | assert dep_version_from_title(title) == upgrade 52 | assert dep_versions_from_pr(FakePR(title=title, body="")) == upgrade 53 | 54 | 55 | @pytest.mark.parametrize( 56 | "old_version, new_version, change", 57 | [ 58 | ("20.2.4", "20.3", "minor"), 59 | ("4.17.15", "4.17.19", "patch"), 60 | ("0.2", "0.3", "minor"), 61 | ("2.9.10.1", "2.10.0.pr1", "minor"), 62 | ("4.0", "4.1", "minor"), 63 | ("1.5", "2.0", "major"), 64 | ("feb", "may", None), 65 | ], 66 | ) 67 | def test_compare_versions( 68 | old_version: str, new_version: str, change: str | None 69 | ) -> None: 70 | assert _compare_versions(old_version=old_version, new_version=new_version) == change 71 | -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/test_dependencies.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from collections.abc import Iterator 5 | from dataclasses import dataclass 6 | from pathlib import Path 7 | 8 | import pytest 9 | 10 | from kodiak.dependencies import dep_versions_from_pr 11 | 12 | 13 | @dataclass 14 | class FakePR: 15 | title: str 16 | body: str 17 | 18 | 19 | def generate_test_cases() -> Iterator[tuple[FakePR, str]]: 20 | """ 21 | Generate test cases from the renovate_pull_requests/ directory. 22 | 23 | Each example file name has the update type. For example, 24 | (update-major-single_pr.txt) would be a major update. 25 | 26 | The first line of the file is the PR title. The remainder is the body. 27 | """ 28 | update_type_regex = re.compile("^update-(?P.*)-.*.txt$") 29 | renovate_examples = Path(__file__).parent.parent / "dependencies" / "pull_requests" 30 | 31 | for file_name in renovate_examples.glob("*"): 32 | match = update_type_regex.match(file_name.name) 33 | assert match is not None 34 | update_type = match.groupdict()["update_type"] 35 | title, *rest = file_name.read_text().splitlines() 36 | body = "\n".join(rest) 37 | yield FakePR(title, body), update_type 38 | 39 | 40 | @pytest.mark.parametrize("pr,update_type", generate_test_cases()) 41 | def test_merge_renovate(pr: FakePR, update_type: str) -> None: 42 | assert dep_versions_from_pr(pr) == update_type 43 | -------------------------------------------------------------------------------- /bot/kodiak/tests/dependencies/test_renovate.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for renovate-specific dependency behavior. 3 | 4 | See test_dependencies and the renovate_pull_requests/ folder for more tests 5 | using full PR examples. 6 | """ 7 | from __future__ import annotations 8 | 9 | from kodiak.dependencies import dep_versions_from_renovate_pr_body 10 | 11 | 12 | def test_renovate_minor_major() -> None: 13 | """ 14 | Test with minor and major upgrades in PR. 15 | 16 | Example modified from https://github.com/netlify/cli/pull/2998 17 | """ 18 | renovate_body = r""" 19 | 20 | | Package | Change | Age | Adoption | Passing | Confidence | 21 | |---|---|---|---|---|---| 22 | | [@netlify/build](https://togithub.com/netlify/build) | [`^16.2.1` -> `^16.3.5`](https://renovatebot.com/diffs/npm/@netlify%2fbuild/16.2.1/16.3.5) | [![age](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.3.5/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.3.5/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.3.5/compatibility-slim/16.2.1)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.3.5/confidence-slim/16.2.1)](https://docs.renovatebot.com/merge-confidence/) | 23 | | [@netlify/config](https://togithub.com/netlify/build) | [`^13.0.0` -> `^14.0.0`](https://renovatebot.com/diffs/npm/@netlify%2fconfig/13.0.0/14.0.0) | [![age](https://badges.renovateapi.com/packages/npm/@netlify%2fconfig/14.0.0/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/@netlify%2fconfig/14.0.0/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/@netlify%2fconfig/14.0.0/compatibility-slim/13.0.0)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/@netlify%2fconfig/14.0.0/confidence-slim/13.0.0)](https://docs.renovatebot.com/merge-confidence/) | 24 | 25 | --- 26 | """ 27 | 28 | assert dep_versions_from_renovate_pr_body(renovate_body) == "major" 29 | 30 | 31 | def test_renovate_unknown() -> None: 32 | """ 33 | With an unknown version ("16.2.1-beta") we should return None. 34 | """ 35 | renovate_body = r""" 36 | 37 | | Package | Change | Age | Adoption | Passing | Confidence | 38 | |---|---|---|---|---|---| 39 | | [@netlify/build](https://togithub.com/netlify/build) | [`^16.2.1-beta` -> `^16.2.1`](https://renovatebot.com/diffs/npm/@netlify%2fbuild/16.2.1-beta/16.2.1) | [![age](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.2.1/age-slim)](https://docs.renovatebot.com/merge-confidence/) | [![adoption](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.2.1/adoption-slim)](https://docs.renovatebot.com/merge-confidence/) | [![passing](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.2.1/compatibility-slim/16.2.1-beta)](https://docs.renovatebot.com/merge-confidence/) | [![confidence](https://badges.renovateapi.com/packages/npm/@netlify%2fbuild/16.2.1/confidence-slim/16.2.1-beta)](https://docs.renovatebot.com/merge-confidence/) | 40 | 41 | --- 42 | """ 43 | 44 | assert dep_versions_from_renovate_pr_body(renovate_body) is None 45 | -------------------------------------------------------------------------------- /bot/kodiak/tests/evaluation/test_merge_message.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for general merge.message configuration options. 3 | 4 | Some configuration options have their own specific test files. 5 | """ 6 | 7 | from kodiak.config import ( 8 | V1, 9 | Merge, 10 | MergeBodyStyle, 11 | MergeMessage, 12 | MergeMethod, 13 | MergeTitleStyle, 14 | ) 15 | from kodiak.evaluation import MergeBody, get_merge_body 16 | from kodiak.test_evaluation import create_pull_request 17 | 18 | 19 | def test_pr_get_merge_body_full() -> None: 20 | pull_request = create_pull_request() 21 | actual = get_merge_body( 22 | config=V1( 23 | version=1, 24 | merge=Merge( 25 | message=MergeMessage( 26 | title=MergeTitleStyle.pull_request_title, 27 | body=MergeBodyStyle.pull_request_body, 28 | include_pr_number=True, 29 | ) 30 | ), 31 | ), 32 | pull_request=pull_request, 33 | merge_method=MergeMethod.squash, 34 | commits=[], 35 | ) 36 | expected = MergeBody( 37 | merge_method="squash", 38 | commit_title=pull_request.title + f" (#{pull_request.number})", 39 | commit_message=pull_request.body, 40 | ) 41 | assert expected == actual 42 | 43 | 44 | def test_pr_get_merge_body_empty() -> None: 45 | pull_request = create_pull_request() 46 | actual = get_merge_body( 47 | config=V1(version=1), 48 | pull_request=pull_request, 49 | merge_method=MergeMethod.squash, 50 | commits=[], 51 | ) 52 | expected = MergeBody(merge_method="squash") 53 | assert actual == expected 54 | 55 | 56 | def test_get_merge_body_strip_html_comments() -> None: 57 | pull_request = create_pull_request() 58 | pull_request.body = "hello world" 59 | actual = get_merge_body( 60 | config=V1( 61 | version=1, 62 | merge=Merge( 63 | message=MergeMessage( 64 | body=MergeBodyStyle.pull_request_body, strip_html_comments=True 65 | ) 66 | ), 67 | ), 68 | pull_request=pull_request, 69 | merge_method=MergeMethod.squash, 70 | commits=[], 71 | ) 72 | expected = MergeBody(merge_method="squash", commit_message="hello world") 73 | assert actual == expected 74 | 75 | 76 | def test_get_merge_body_empty() -> None: 77 | pull_request = create_pull_request() 78 | pull_request.body = "hello world" 79 | actual = get_merge_body( 80 | config=V1( 81 | version=1, merge=Merge(message=MergeMessage(body=MergeBodyStyle.empty)) 82 | ), 83 | pull_request=pull_request, 84 | merge_method=MergeMethod.squash, 85 | commits=[], 86 | ) 87 | expected = MergeBody(merge_method="squash", commit_message="") 88 | assert actual == expected 89 | -------------------------------------------------------------------------------- /bot/kodiak/tests/evaluation/test_merge_method.py: -------------------------------------------------------------------------------- 1 | from kodiak.config import MergeMethod 2 | from kodiak.test_evaluation import create_api, create_config, create_mergeable 3 | 4 | 5 | async def test_rebase_merge_fast_forward() -> None: 6 | """ 7 | Happy case. 8 | 9 | """ 10 | mergeable = create_mergeable() 11 | api = create_api() 12 | config = create_config() 13 | config.merge.method = MergeMethod.rebase_fast_forward 14 | await mergeable(api=api, config=config, merging=True) 15 | 16 | assert api.update_ref.call_count == 1 17 | assert api.merge.call_count == 0 18 | assert api.set_status.call_count == 2 19 | assert "(merging)" in api.set_status.calls[0]["msg"] 20 | assert api.queue_for_merge.call_count == 0 21 | assert api.dequeue.call_count == 0 22 | -------------------------------------------------------------------------------- /bot/kodiak/tests/event_handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/bot/kodiak/tests/event_handlers/__init__.py -------------------------------------------------------------------------------- /bot/kodiak/tests/event_handlers/test_check_run.py: -------------------------------------------------------------------------------- 1 | from kodiak.events.base import Installation 2 | from kodiak.events.check_run import ( 3 | CheckRun, 4 | CheckRunEvent, 5 | Owner, 6 | PullRequest, 7 | PullRequestRepository, 8 | Ref, 9 | Repository, 10 | ) 11 | from kodiak.queue import WebhookEvent, check_run 12 | 13 | 14 | def test_ignore_external_pull_requests() -> None: 15 | """ 16 | We should filter out pull requests that don't belong to our repository. 17 | """ 18 | internal_repository_id = 554453 19 | internal_pull_request = PullRequest( 20 | number=123, 21 | base=Ref(ref="main", repo=PullRequestRepository(id=internal_repository_id)), 22 | ) 23 | external_pull_request = PullRequest( 24 | number=534, base=Ref(ref="main", repo=PullRequestRepository(id=22)) 25 | ) 26 | event = CheckRunEvent( 27 | installation=Installation(id=69039045), 28 | check_run=CheckRun( 29 | name="circleci: build", 30 | pull_requests=[external_pull_request, internal_pull_request], 31 | ), 32 | repository=Repository( 33 | id=internal_repository_id, name="cake-api", owner=Owner(login="acme-corp") 34 | ), 35 | ) 36 | assert list(check_run(event)) == [ 37 | WebhookEvent( 38 | repo_owner="acme-corp", 39 | repo_name="cake-api", 40 | pull_request_number=internal_pull_request.number, 41 | installation_id=str(event.installation.id), 42 | target_name="main", 43 | ) 44 | ] 45 | -------------------------------------------------------------------------------- /bot/kodiak/tests/fixtures.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | import pytest 4 | 5 | from kodiak import app_config as conf 6 | from kodiak.queries import Commit, CommitConnection, GitActor, PullRequestCommitUser 7 | 8 | 9 | def create_commit( 10 | *, 11 | database_id: Optional[int], 12 | name: Optional[str], 13 | login: str, 14 | type: str, 15 | parents: int = 1, 16 | ) -> Commit: 17 | return Commit( 18 | parents=CommitConnection(totalCount=parents), 19 | author=GitActor( 20 | user=PullRequestCommitUser( 21 | databaseId=database_id, name=name, login=login, type=type 22 | ) 23 | ), 24 | ) 25 | 26 | 27 | class FakeThottler: 28 | async def __aenter__(self) -> None: 29 | ... 30 | 31 | async def __aexit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: 32 | ... 33 | 34 | 35 | def redis_running() -> bool: 36 | """ 37 | Check if service is listening at the REDIS host and port. 38 | """ 39 | import socket 40 | 41 | s = socket.socket() 42 | host = conf.REDIS_URL.hostname 43 | port = conf.REDIS_URL.port 44 | assert host and port 45 | try: 46 | s.connect((host, port)) 47 | s.close() 48 | return True 49 | except ConnectionRefusedError: 50 | return False 51 | 52 | 53 | requires_redis = pytest.mark.skipif(not redis_running(), reason="redis is not running") 54 | -------------------------------------------------------------------------------- /bot/kodiak/tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | from kodiak.queries import ThrottlerProtocol 2 | from kodiak.tests.fixtures import FakeThottler 3 | 4 | 5 | async def test_fake_throttler() -> None: 6 | throttler: ThrottlerProtocol = FakeThottler() 7 | async with throttler: 8 | pass 9 | -------------------------------------------------------------------------------- /bot/kodiak/tests/test_messages.py: -------------------------------------------------------------------------------- 1 | from kodiak.messages import get_markdown_for_api_call_errors 2 | from kodiak.pull_request import APICallError 3 | 4 | 5 | def test_get_markdown_for_api_call_errors() -> None: 6 | api_call_retry = APICallError( 7 | api_name="pull_request/merge", 8 | http_status="405", 9 | response_body='{"message":"This branch must not contain merge commits.","documentation_url":"https://docs.github.com/articles/about-protected-branches”}', 10 | ) 11 | 12 | assert ( 13 | get_markdown_for_api_call_errors(errors=[api_call_retry, api_call_retry]) 14 | == """\ 15 | Errors encountered when contacting GitHub API. 16 | 17 | - API call 'pull_request/merge' failed with HTTP status '405' and response: '{"message":"This branch must not contain merge commits.","documentation_url":"https://docs.github.com/articles/about-protected-branches”}' 18 | - API call 'pull_request/merge' failed with HTTP status '405' and response: '{"message":"This branch must not contain merge commits.","documentation_url":"https://docs.github.com/articles/about-protected-branches”}' 19 | 20 | 21 | If you need help, you can open a GitHub issue, check the docs, or reach us privately at support@kodiakhq.com. 22 | 23 | [docs](https://kodiakhq.com/docs/troubleshooting) | [dashboard](https://app.kodiakhq.com) | [support](https://kodiakhq.com/help) 24 | 25 | """ 26 | ) 27 | -------------------------------------------------------------------------------- /bot/kodiak/throttle.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | from collections import defaultdict, deque 4 | from typing import Any, Mapping 5 | 6 | from typing_extensions import Deque 7 | 8 | 9 | class Throttler: 10 | """ 11 | via https://github.com/hallazzang/asyncio-throttle 12 | 13 | The MIT License (MIT) 14 | 15 | Copyright (c) 2017-2019 Hanjun Kim 16 | 17 | Permission is hereby granted, free of charge, to any person obtaining a copy 18 | of this software and associated documentation files (the "Software"), to deal 19 | in the Software without restriction, including without limitation the rights 20 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 21 | copies of the Software, and to permit persons to whom the Software is 22 | furnished to do so, subject to the following conditions: 23 | 24 | The above copyright notice and this permission notice shall be included in all 25 | copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 28 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 29 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 30 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 31 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 32 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 33 | SOFTWARE. 34 | """ 35 | 36 | def __init__( 37 | self, rate_limit: float, period: float = 1.0, retry_interval: float = 0.01 38 | ) -> None: 39 | self.rate_limit = rate_limit 40 | self.period = period 41 | self.retry_interval = retry_interval 42 | 43 | self._task_logs: Deque[float] = deque() 44 | 45 | def flush(self) -> None: 46 | now = time.time() 47 | while self._task_logs: 48 | if now - self._task_logs[0] > self.period: 49 | self._task_logs.popleft() 50 | else: 51 | break 52 | 53 | async def acquire(self) -> None: 54 | while True: 55 | self.flush() 56 | if len(self._task_logs) < self.rate_limit: 57 | break 58 | await asyncio.sleep(self.retry_interval) 59 | 60 | self._task_logs.append(time.time()) 61 | 62 | async def __aenter__(self) -> None: 63 | await self.acquire() 64 | 65 | async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: 66 | pass 67 | 68 | 69 | # installation_id => Throttler 70 | THROTTLER_CACHE: Mapping[str, Throttler] = defaultdict( 71 | # TODO(chdsbd): Store rate limits in redis and update via http rate limit response headers 72 | lambda: Throttler(rate_limit=5000 / 60 / 60) 73 | ) 74 | 75 | 76 | def get_thottler_for_installation(*, installation_id: str) -> Throttler: 77 | return THROTTLER_CACHE[installation_id] 78 | -------------------------------------------------------------------------------- /bot/s/dev-ingest: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | ./.venv/bin/uvicorn kodiak.entrypoints.ingest:app "$@" 5 | -------------------------------------------------------------------------------- /bot/s/dev-workers: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | exec ./.venv/bin/python3 -m kodiak.entrypoints.worker 5 | -------------------------------------------------------------------------------- /bot/s/fmt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | ./.venv/bin/black . 4 | -------------------------------------------------------------------------------- /bot/s/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | # format code 5 | if [[ $CI ]]; then 6 | ./.venv/bin/black --check . 7 | ./.venv/bin/isort --check-only 8 | else 9 | ./.venv/bin/black . 10 | ./.venv/bin/isort -y 11 | fi 12 | 13 | # type check code 14 | ./.venv/bin/mypy . 15 | 16 | # lint 17 | ./.venv/bin/flake8 kodiak 18 | ./.venv/bin/pylint --rcfile='.pylintrc' kodiak 19 | -------------------------------------------------------------------------------- /bot/s/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | 5 | if [ "$CI" ]; then 6 | ./.venv/bin/pytest --cov=. --cov-report xml "$@" 7 | else 8 | ./.venv/bin/pytest "$@" 9 | fi 10 | -------------------------------------------------------------------------------- /bot/s/typecheck: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec ./.venv/bin/mypy . 3 | -------------------------------------------------------------------------------- /bot/s/upload-code-cov: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | bash <(curl -s https://codecov.io/bash) 6 | -------------------------------------------------------------------------------- /bot/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:ingest] 5 | command=/var/app/.venv/bin/python -m kodiak.entrypoints.ingest 6 | stdout_logfile=/dev/stdout 7 | stdout_logfile_maxbytes=0 8 | 9 | [program:worker] 10 | command=/var/app/.venv/bin/python -m kodiak.entrypoints.worker 11 | stdout_logfile=/dev/stdout 12 | stdout_logfile_maxbytes=0 13 | -------------------------------------------------------------------------------- /bot/tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | banned-modules = 3 | httpx.* = Use kodiak.http 4 | ban-relative-imports = true 5 | ignore = 6 | ; formatting handled by black 7 | ; https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes 8 | ; https://github.com/ambv/black/issues/429 9 | E101, 10 | E111, 11 | E114, 12 | E115, 13 | E116, 14 | E117, 15 | E121, 16 | E122, 17 | E123, 18 | E124, 19 | E125, 20 | E126, 21 | E127, 22 | E128, 23 | E129, 24 | E131, 25 | E133, 26 | E2, 27 | E3, 28 | E5, 29 | E701, 30 | E702, 31 | E703, 32 | E704, 33 | W1, 34 | W2, 35 | W3, 36 | W503, 37 | W504, 38 | # undefined variables are covered by mypy 39 | F821, 40 | -------------------------------------------------------------------------------- /bot/typings/jwt.pyi: -------------------------------------------------------------------------------- 1 | from typing_extensions import Literal 2 | 3 | def encode( 4 | *, payload: dict[str, object], key: str, algorithm: Literal["RS256"] 5 | ) -> bytes: ... 6 | -------------------------------------------------------------------------------- /bot/typings/markdown_html_finder.pyi: -------------------------------------------------------------------------------- 1 | def find_html_positions(x: str) -> list[tuple[int, int]]: ... 2 | -------------------------------------------------------------------------------- /bot/typings/markupsafe.pyi: -------------------------------------------------------------------------------- 1 | def escape(__: str) -> str: ... 2 | -------------------------------------------------------------------------------- /bot/typings/rure/__init__.pyi: -------------------------------------------------------------------------------- 1 | from re import Match, RegexFlag 2 | from typing import AnyStr, Optional, Pattern, overload 3 | 4 | from rure import exceptions 5 | 6 | __all__ = ["exceptions"] 7 | 8 | _FlagsType = RegexFlag 9 | 10 | @overload 11 | def search( 12 | pattern: AnyStr, string: AnyStr, flags: _FlagsType = ... 13 | ) -> Optional[Match[AnyStr]]: ... 14 | @overload 15 | def search( 16 | pattern: Pattern[AnyStr], string: AnyStr, flags: _FlagsType = ... 17 | ) -> Optional[Match[AnyStr]]: ... 18 | -------------------------------------------------------------------------------- /bot/typings/rure/exceptions.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | class RegexError(Exception): 4 | message: str 5 | def __init__(self, message: str, *args: Any) -> None: ... 6 | 7 | class RegexSyntaxError(RegexError): ... 8 | class CompiledTooBigError(RegexError): ... 9 | -------------------------------------------------------------------------------- /bot/typings/structlog/__init__.pyi: -------------------------------------------------------------------------------- 1 | from structlog import processors as processors # noqa: F401 2 | from structlog import stdlib as stdlib # noqa: F401 3 | from structlog._config import configure as configure # noqa: F401 4 | from structlog._config import get_logger as get_logger # noqa: F401 5 | from structlog._config import reset_defaults as reset_defaults # noqa: F401 6 | from structlog.stdlib import BoundLogger as BoundLogger # noqa: F401 7 | -------------------------------------------------------------------------------- /bot/typings/structlog/_base.pyi: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, TypeVar 4 | 5 | _T = TypeVar("_T", bound="BoundLoggerBase") 6 | 7 | class BoundLoggerBase: 8 | def __init__(self, logger: Any, processors: Any, context: Any) -> None: ... 9 | def bind(self: _T, **new_values: Any) -> _T: ... 10 | def unbind(self: _T, *keys: str) -> _T: ... 11 | def try_unbind(self: _T, *keys: str) -> _T: ... 12 | def new(self: _T, **new_values: Any) -> _T: ... 13 | -------------------------------------------------------------------------------- /bot/typings/structlog/_config.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from structlog.stdlib import BoundLogger 4 | 5 | def reset_defaults() -> None: ... 6 | def configure( 7 | processors: Optional[Any] = ..., 8 | wrapper_class: Optional[Any] = ..., 9 | context_class: Optional[Any] = ..., 10 | logger_factory: Optional[Any] = ..., 11 | cache_logger_on_first_use: Optional[Any] = ..., 12 | ) -> None: ... 13 | def get_logger(*args: Any, **initial_values: Any) -> BoundLogger: ... 14 | -------------------------------------------------------------------------------- /bot/typings/structlog/_generic.pyi: -------------------------------------------------------------------------------- 1 | from structlog._base import BoundLoggerBase 2 | 3 | class BoundLogger(BoundLoggerBase): ... 4 | -------------------------------------------------------------------------------- /bot/typings/structlog/processors.pyi: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Dict, Optional, Sequence 2 | 3 | _EventDict = Dict[str, Any] 4 | 5 | class KeyValueRenderer: 6 | def __init__( 7 | self, 8 | sort_keys: bool = ..., 9 | key_order: Optional[Sequence[str]] = ..., 10 | drop_missing: bool = ..., 11 | repr_native_str: bool = ..., 12 | ) -> None: ... 13 | def __call__(self, _: Any, __: Any, event_dict: _EventDict) -> Any: ... 14 | 15 | class UnicodeEncoder: 16 | def __init__(self, encoding: str = ..., errors: str = ...) -> None: ... 17 | def __call__( 18 | self, logger: Any, name: str, event_dict: _EventDict 19 | ) -> _EventDict: ... 20 | 21 | class UnicodeDecoder: 22 | def __init__(self, encoding: str = ..., errors: str = ...) -> None: ... 23 | def __call__( 24 | self, logger: Any, name: str, event_dict: _EventDict 25 | ) -> _EventDict: ... 26 | 27 | class JSONRenderer: 28 | def __init__( 29 | self, serializer: Callable[..., str] = ..., **dumps_kw: Any 30 | ) -> None: ... 31 | def __call__(self, logger: Any, name: str, event_dict: _EventDict) -> str: ... 32 | 33 | def format_exc_info(logger: Any, name: str, event_dict: _EventDict) -> _EventDict: ... 34 | 35 | class TimeStamper: ... 36 | 37 | class ExceptionPrettyPrinter: 38 | def __init__(self, file: Optional[Any] = ...) -> None: ... 39 | def __call__( 40 | self, logger: Any, name: str, event_dict: _EventDict 41 | ) -> _EventDict: ... 42 | 43 | class StackInfoRenderer: 44 | def __call__( 45 | self, logger: Any, name: str, event_dict: _EventDict 46 | ) -> _EventDict: ... 47 | -------------------------------------------------------------------------------- /bot/typings/structlog/stdlib.pyi: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Dict, List, Optional, Union 3 | 4 | from structlog._base import BoundLoggerBase 5 | 6 | _EventDict = Dict[str, Any] 7 | 8 | class BoundLogger(BoundLoggerBase): 9 | def debug(self, event: Optional[Any] = ..., *args: Any, **kw: Any) -> None: ... 10 | def info(self, event: Optional[Any] = ..., *args: Any, **kw: Any) -> None: ... 11 | def warning(self, event: Optional[Any] = ..., *args: Any, **kw: Any) -> None: ... 12 | warn = warning 13 | def error(self, event: Optional[Any] = ..., *args: Any, **kw: Any) -> None: ... 14 | def critical(self, event: Optional[Any] = ..., *args: Any, **kw: Any) -> None: ... 15 | def exception(self, event: Optional[Any] = ..., *args: Any, **kw: Any) -> None: ... 16 | def log(self, event: Optional[Any] = ..., *args: Any, **kw: Any) -> None: ... 17 | fatal = critical 18 | 19 | def filter_by_level(logger: Any, name: str, event_dict: _EventDict) -> _EventDict: ... 20 | 21 | class PositionalArgumentsFormatter: 22 | def __init__(self, remove_positional_args: bool = ...) -> None: ... 23 | def __call__(self, _: Any, __: Any, event_dict: _EventDict) -> _EventDict: ... 24 | 25 | class LoggerFactory: 26 | def __init__( 27 | self, ignore_frame_names: Optional[Union[List[str], str]] = ... 28 | ) -> None: ... 29 | def __call__(self, *args: Any) -> logging.Logger: ... 30 | -------------------------------------------------------------------------------- /bot/typings/uvicorn/__init__.pyi: -------------------------------------------------------------------------------- 1 | from uvicorn.main import run 2 | 3 | __all__ = ["run"] 4 | -------------------------------------------------------------------------------- /bot/typings/uvicorn/main.pyi: -------------------------------------------------------------------------------- 1 | def run(app: str, *, host: str = ..., port: int = ..., **kwargs: object) -> None: ... 2 | -------------------------------------------------------------------------------- /bot/typings/zstandard.pyi: -------------------------------------------------------------------------------- 1 | class ZstdCompressor: 2 | def compress(self, arg: object) -> bytes: ... 3 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: false 3 | 4 | comment: false 5 | 6 | coverage: 7 | status: 8 | project: 9 | default: 10 | informational: true 11 | patch: 12 | default: 13 | informational: true 14 | -------------------------------------------------------------------------------- /docs/.prettierrc.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/docs/en/options.html 2 | module.exports = { 3 | semi: false, 4 | useTabs: false, 5 | tabWidth: 2, 6 | singleQuote: false, 7 | trailingComma: "es5", 8 | bracketSpacing: true, 9 | jsxBracketSameLine: true, 10 | arrowParens: "avoid", 11 | } 12 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Kodiak Docs 2 | 3 | The docs site uses . 4 | 5 | For the most part content is created with markdown and placed in the `docs/` 6 | folder. Some pages like the `index.html` and the help page require editing 7 | React based code. 8 | 9 | ## Adding a New Page 10 | 11 | Add another markdown file to the `docs/` folder and update the 12 | `sidebars.json` with the new doc's id. 13 | 14 | ## Dev 15 | 16 | ```shell 17 | # docs/ 18 | yarn install 19 | s/dev 20 | s/typecheck --watch 21 | s/fmt 22 | ALGOLIA_APP_ID= AGOLIA_API_KEY= AGOLIA_INDEX_NAME= yarn build 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/core/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require("react") 9 | 10 | /** @param {{config: typeof import("../siteConfig")}} props */ 11 | function Footer(props) { 12 | return ( 13 |
55 | ) 56 | } 57 | 58 | module.exports = Footer 59 | -------------------------------------------------------------------------------- /docs/docs/dashboard.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: dashboard 3 | title: Dashboard 4 | --- 5 | 6 | The Kodiak web dashboard includes activity graphs, merge queue information, and billing management. 7 | 8 | The Kodiak dashboard is accessible at https://app.kodiakhq.com. 9 | 10 | ## Activity 11 | 12 | The activity page is the main landing page for the Kodiak dashboard. It shows activity charts and merge queue information. 13 | 14 | 15 | 16 | The pull request activity chart shows the number of pull requests opened, closed, and merged per day. 17 | 18 | 19 | 20 | The Kodiak activity chart shows the number of pull requests merged by Kodiak per day. 21 | 22 | 23 | 24 | All merge queues with pull requests are displayed at the bottom of the activity page. 25 | 26 | 27 | 28 | ## Usage & Billing 29 | 30 | The usage and billing page displays trial and subscription information. You can start a trial or subscription via this page, update billing details and view subscription usage information. 31 | 32 | See the [billing docs](./billing.md) for information about starting a trial or modifying a subscription. 33 | 34 | 35 | 36 | Seat license usage information is available at the bottom of the "Usage & Billing" page. 37 | 38 | -------------------------------------------------------------------------------- /docs/docs/quickstart.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: quickstart 3 | title: Quick Start 4 | sidebar_label: Quick Start 5 | --- 6 | 7 | 1. Install [the GitHub app](https://github.com/marketplace/kodiakhq#pricing-and-setup) 8 | 9 | 2. Create a `.kodiak.toml` file in the root of your repository with the following contents 10 | 11 | ```toml 12 | # .kodiak.toml 13 | # Minimal config. version is the only required field. 14 | version = 1 15 | ``` 16 | 17 | 3. Configure [GitHub branch protection](https://help.github.com/en/articles/configuring-protected-branches). Setup [required status checks](https://docs.github.com/en/github/administering-a-repository/enabling-required-status-checks) to prevent failing PRs from being merged. 18 | 19 | 4. Create an automerge label (default: "automerge") 20 | 21 | 5. Start auto merging PRs with Kodiak 22 | 23 | Label your PRs with your `automerge` label and let Kodiak do the rest! 🎉 24 | 25 | If you have any questions please review [our help page](/help). 26 | -------------------------------------------------------------------------------- /docs/docs/sponsoring.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: sponsoring 3 | title: 💸 Sponsoring 4 | sidebar_label: Sponsoring 5 | --- 6 | 7 | Using Kodiak for your commercial project? 8 | 9 | [Support Kodiak with GitHub Sponsors](https://github.com/sponsors/chdsbd) to help cover server costs and support development. 10 | -------------------------------------------------------------------------------- /docs/docs/why-and-how.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: why-and-how 3 | title: Why and How 4 | --- 5 | 6 | ## Why? 7 | 8 | Enabling the "require branches be up to date" feature on GitHub repositories is 9 | great because, when coupled with CI, _master will always be green_. 10 | 11 | However, as the number of collaborators on a GitHub repo increases, a 12 | repetitive behavior emerges where contributors are updating their branches 13 | manually hoping to merge their branch before others. 14 | 15 | Kodiak fixes this wasteful behavior by _automatically updating 16 | and merging branches_. Contributors simply mark their 17 | PR with a (configurable) label to indicate the PR is ready to merge and Kodiak 18 | will do the rest; handling branch updates and merging using the _minimal 19 | number of branch updates_ to land the code on master. 20 | 21 | This means that contributors don't have to worry about keeping their PRs up 22 | to date with the latest on master or even pressing the merge button. Kodiak 23 | does this for them! 🎉 24 | 25 | Additionally this introduces fairness to the PR merge process as ready to 26 | merge PRs in the merge queue are merged on a first come, first served basis. 27 | 28 | ### Minimal updates 29 | 30 | Kodiak _efficiently updates pull requests_ by only updating a PR when it's ready to merge. This 31 | _prevents spurious CI jobs_ from being created as they would if all PRs were 32 | updated when their targets were updated. 33 | 34 | ## How does it work? 35 | 36 | 1. Kodiak receives a webhook event from GitHub and adds it to a per-installation queue for processing 37 | 2. Kodiak processes these webhook events and extracts the associated pull 38 | requests for further processing 39 | 40 | 3. Pull request mergeability is evaluated using PR data 41 | 42 | - kodiak configurations are checked 43 | - pull request merge states are evaluated 44 | - branch protection rules are checked 45 | - the branch is updated if necessary 46 | 47 | 4. If the PR is mergeable it's queued in a per-repo merge queue 48 | 49 | 5. A task works serially over the merge queue to update a PR and merge it 50 | 51 | 6. The pull request is merged 🎉 52 | -------------------------------------------------------------------------------- /docs/netlify.toml: -------------------------------------------------------------------------------- 1 | # see: https://docs.netlify.com/configure-builds/file-based-configuration/ 2 | [[redirects]] 3 | from = "/docs/detailed-setup" 4 | to = "/docs/config-reference" 5 | status = 302 6 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "volta": { 4 | "node": "12.4.0", 5 | "yarn": "1.22.17" 6 | }, 7 | "devDependencies": { 8 | "@types/react": "^16.9.17", 9 | "docusaurus": "^1.14.3", 10 | "prettier": "^1.19.1", 11 | "typescript": "^3.7.4" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/pages/en/help.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require("react") 9 | 10 | // @ts-ignore 11 | const CompLibrary = require("../../core/CompLibrary.js") 12 | 13 | const Container = CompLibrary.Container 14 | const GridBlock = CompLibrary.GridBlock 15 | 16 | /** @param {{config: typeof import("../../siteConfig")}} props */ 17 | function Help(props) { 18 | const supportLinks = [ 19 | { 20 | content: `If you need help installing or configuring Kodiak please [open an issue on GitHub](${props.config.issuesUrl}). 21 | 22 | The team is happy to help!`, 23 | title: `[File an Issue on GitHub](${props.config.issuesUrl})`, 24 | }, 25 | { 26 | content: `Take a look around Kodiak's [Troubleshooting page](/docs/troubleshooting) and [Quick Start Guide](/docs/quickstart).`, 27 | title: "Browse Docs", 28 | }, 29 | { 30 | content: `Reach us privately at support@kodiakhq.com.`, 31 | title: `Send an Email`, 32 | }, 33 | ] 34 | 35 | return ( 36 |
37 | 38 |
39 |
40 |

Need help?

41 |
42 | 43 |
44 |
45 |
46 | ) 47 | } 48 | 49 | module.exports = Help 50 | -------------------------------------------------------------------------------- /docs/s/build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Netlify sets FORCE_COLOR=true and a bug in Yarn causes `yarn bin` to return an 3 | # invalid path: `/opt/build/repo/docs/node_modules/.bin` 4 | # https://github.com/yarnpkg/yarn/issues/5945 5 | exec "$(FORCE_COLOR=0 yarn bin)/docusaurus-build" 6 | -------------------------------------------------------------------------------- /docs/s/dev: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec "$(yarn bin)/docusaurus-start" 3 | -------------------------------------------------------------------------------- /docs/s/fmt: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec "$(yarn bin)/prettier" '**/*.{md,js,json}' --write 3 | -------------------------------------------------------------------------------- /docs/s/fmt-ci: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec "$(yarn bin)/prettier" '**/*.{md,js,json}' --check 3 | -------------------------------------------------------------------------------- /docs/s/typecheck: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec "$(yarn bin)/tsc" --project tsconfig.json "$@" 3 | -------------------------------------------------------------------------------- /docs/sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": { 3 | "Getting Started": [ 4 | "quickstart", 5 | "config-reference", 6 | "features", 7 | "recipes", 8 | "dashboard", 9 | "billing", 10 | "troubleshooting", 11 | "permissions", 12 | "self-hosting", 13 | "why-and-how", 14 | "prior-art-and-alternatives" 15 | ], 16 | "Contributing": ["contributing", "sponsoring"] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docs/static/img/billing-modify-card-step-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/billing-modify-card-step-1.png -------------------------------------------------------------------------------- /docs/static/img/billing-modify-card-step-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/billing-modify-card-step-2.png -------------------------------------------------------------------------------- /docs/static/img/billing-modify-card-step-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/billing-modify-card-step-3.png -------------------------------------------------------------------------------- /docs/static/img/branch-protection-require-branches-up-to-date.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/branch-protection-require-branches-up-to-date.png -------------------------------------------------------------------------------- /docs/static/img/branch-protection-require-signed-commits.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/branch-protection-require-signed-commits.png -------------------------------------------------------------------------------- /docs/static/img/coauthors-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/coauthors-example.png -------------------------------------------------------------------------------- /docs/static/img/dashboard/billing-trial-signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/dashboard/billing-trial-signup.png -------------------------------------------------------------------------------- /docs/static/img/dashboard/kodiak-activity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/dashboard/kodiak-activity.png -------------------------------------------------------------------------------- /docs/static/img/dashboard/merge-queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/dashboard/merge-queue.png -------------------------------------------------------------------------------- /docs/static/img/dashboard/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/dashboard/overview.png -------------------------------------------------------------------------------- /docs/static/img/dashboard/pull-request-activity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/dashboard/pull-request-activity.png -------------------------------------------------------------------------------- /docs/static/img/dashboard/trial-signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/dashboard/trial-signup.png -------------------------------------------------------------------------------- /docs/static/img/dashboard/usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/dashboard/usage.png -------------------------------------------------------------------------------- /docs/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/favicon.ico -------------------------------------------------------------------------------- /docs/static/img/logo_complexgmbh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/logo_complexgmbh.png -------------------------------------------------------------------------------- /docs/static/img/restrict-who-can-push-to-matching-branches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/restrict-who-can-push-to-matching-branches.png -------------------------------------------------------------------------------- /docs/static/img/wordmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/docs/static/img/wordmark.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": true, 4 | "allowJs": true, 5 | "checkJs": true, 6 | "target": "es2017", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "skipLibCheck": true, 14 | "esModuleInterop": true, 15 | "jsx": "react", 16 | "noEmit": true 17 | }, 18 | "include": ["**/*.js"], 19 | "exclude": ["build", "node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /infrastructure/README.md: -------------------------------------------------------------------------------- 1 | # infrastructure 2 | 3 | ## deployment 4 | 5 | 1. [install ansible](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html) 6 | 2. setup [ansible inventory](https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html) 7 | 8 | it should look something like: 9 | 10 | `/etc/ansible/hosts` 11 | 12 | ```yaml 13 | --- 14 | all: 15 | children: 16 | kodiak_prod: 17 | hosts: 18 | kodiak_prod_app_server: 19 | ansible_host: 255.255.255.255 20 | ansible_user: root 21 | ansible_python_interpreter: /usr/bin/python3 22 | 23 | kodiak_prod_ingestor: 24 | ansible_host: 255.255.255.255 25 | ansible_user: root 26 | ansible_python_interpreter: /usr/bin/python3 27 | 28 | kodiak_staging: 29 | hosts: 30 | kodiak_staging_ingestor: 31 | ansible_host: 255.255.255.255 32 | ansible_user: root 33 | ansible_python_interpreter: /usr/bin/python3 34 | 35 | kodiak_prod_app_server: 36 | ansible_host: 255.255.255.255 37 | ansible_user: root 38 | ansible_python_interpreter: /usr/bin/python3 39 | ``` 40 | 41 | 3. run the playbook for the specified service (see below) 42 | 43 | ### dashboard 44 | 45 | Note: while the docker containers for the specific git sha are downloaded and 46 | deployed, the templates for the systemd crons are copied directly from your git 47 | repo. Make sure your local changes are in order! 48 | 49 | ```shell 50 | # /kodiak 51 | # note: 52 | # - when deploying to prod, don't forget to change the target 53 | # - use the `--check` flag to see what changes will occur before making them 54 | ansible-playbook -e 'target=kodiak_staging_ingestor' infrastructure/playbooks/dashboard-deploy.yml 55 | ``` 56 | -------------------------------------------------------------------------------- /infrastructure/kodiak-daily-restart.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Restart kodiak 3 | 4 | [Service] 5 | ExecStart=/root/kodiak-restart.sh 6 | -------------------------------------------------------------------------------- /infrastructure/kodiak-daily-restart.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Restart daily at off-peak time 3 | 4 | [Timer] 5 | OnCalendar=*-*-* 04:00:00 UTC 6 | 7 | Persistent=true 8 | 9 | [Install] 10 | WantedBy=timers.target 11 | -------------------------------------------------------------------------------- /infrastructure/kodiak_restart.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | container_id=$(sudo docker ps | grep 'cdignam/kodiak' | awk '{print $1}') 4 | sudo docker restart "$container_id" 5 | -------------------------------------------------------------------------------- /infrastructure/systemd/kodiak-aggregate_pull_request_activity.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Aggregate pull request activity from GitHub events 3 | [Service] 4 | ExecStart=docker run --rm --env-file=/etc/kodiak/.env cdignam/kodiak-web-api:{{ release_sha }} .venv/bin/python ./manage.py aggregate_pull_request_activity 5 | [Install] 6 | WantedBy=multi-user.target 7 | -------------------------------------------------------------------------------- /infrastructure/systemd/kodiak-aggregate_pull_request_activity.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run aggregate_pull_request_activity on cron 3 | [Timer] 4 | Unit=kodiak-aggregate_pull_request_activity.service 5 | OnBootSec=5min 6 | OnUnitActiveSec=5min 7 | [Install] 8 | WantedBy=timers.target 9 | -------------------------------------------------------------------------------- /infrastructure/systemd/kodiak-aggregate_user_pull_request_activity.service.j2: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Aggregate user pull request activity from GitHub events 3 | [Service] 4 | ExecStart=docker run --rm --env-file=/etc/kodiak/.env cdignam/kodiak-web-api:{{ release_sha }} .venv/bin/python ./manage.py aggregate_user_pull_request_activity 5 | [Install] 6 | WantedBy=multi-user.target 7 | -------------------------------------------------------------------------------- /infrastructure/systemd/kodiak-aggregate_user_pull_request_activity.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run aggregate_user_pull_request_activity on cron 3 | [Timer] 4 | Unit=kodiak-aggregate_user_pull_request_activity.service 5 | OnBootSec=5min 6 | OnUnitActiveSec=5min 7 | [Install] 8 | WantedBy=timers.target 9 | -------------------------------------------------------------------------------- /kodiak.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "web_ui" 5 | }, 6 | { 7 | "path": "bot" 8 | }, 9 | { 10 | "path": "web_api" 11 | }, 12 | { 13 | "path": "docs" 14 | }, 15 | { 16 | "path": "infrastructure" 17 | } 18 | ], 19 | "settings": {} 20 | } 21 | -------------------------------------------------------------------------------- /s/shellcheck: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -o nounset 4 | 5 | 6 | main() { 7 | exit_code=0 8 | for f in $(git ls-files); do 9 | if [ -f "$f" ]; then 10 | # matches shebang as well as shell file endings 11 | if grep -Eq '^#!(.*/|.*env +)(sh|bash|ksh)' "$f" || [[ "$f" =~ \.(sh|bash|ksh)$ ]]; then 12 | echo "$f" 13 | shellcheck "$f" 14 | ret_code="$?" 15 | if [ $ret_code != 0 ]; then 16 | exit_code="$ret_code" 17 | fi 18 | fi 19 | fi 20 | done 21 | 22 | exit "$exit_code" 23 | } 24 | 25 | main "$@" 26 | -------------------------------------------------------------------------------- /web_api/.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | .venv/* 4 | venv/* 5 | env/* 6 | .env/* 7 | -------------------------------------------------------------------------------- /web_api/.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | **/*.pyc 3 | **/__pycache__/ 4 | .venv/ 5 | Dockerfile 6 | *.pem 7 | *_cache/ 8 | assets/ 9 | .vscode/ 10 | pip-wheel-metadata/ 11 | *egg-info/ 12 | docs/ 13 | .github 14 | .circleci 15 | -------------------------------------------------------------------------------- /web_api/.pylintrc: -------------------------------------------------------------------------------- 1 | ../bot/.pylintrc -------------------------------------------------------------------------------- /web_api/.python-version: -------------------------------------------------------------------------------- 1 | 3.7.13 2 | -------------------------------------------------------------------------------- /web_api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": ".venv/bin/python", 3 | "python.formatting.provider": "black", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.organizeImports": true 7 | }, 8 | "python.linting.mypyEnabled": true, 9 | "python.testing.pytestArgs": ["kodiak"], 10 | "python.testing.unittestEnabled": false, 11 | "python.testing.nosetestsEnabled": false, 12 | "python.testing.pytestEnabled": true, 13 | "python.linting.flake8Enabled": true, 14 | "python.linting.pylintEnabled": true 15 | } 16 | -------------------------------------------------------------------------------- /web_api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7@sha256:6eaf19442c358afc24834a6b17a3728a45c129de7703d8583392a138ecbdb092 2 | 3 | RUN set -ex && mkdir -p /var/app 4 | 5 | # use cryptography version for poetry that doesn't require Rust 6 | RUN python3 -m pip install cryptography===37.0.4 7 | RUN python3 -m pip install poetry===1.1.8 8 | 9 | RUN poetry config virtualenvs.in-project true 10 | 11 | WORKDIR /var/app 12 | 13 | COPY pyproject.toml poetry.lock /var/app/ 14 | 15 | # install deps 16 | RUN poetry install 17 | 18 | COPY . /var/app 19 | 20 | CMD ["/var/app/.venv/bin/gunicorn", "--bind", "0.0.0.0:5000", "--access-logfile", "-", "--error-logfile", "-", "--capture-output", "--enable-stdio-inheritance", "--access-logformat", "'request=\"%(r)s\" request_time=%(L)s remote_addr=\"%(h)s\" request_id=%({X-Request-Id}i)s response_id=%({X-Response-Id}i)s method=%(m)s protocol=%(H)s status_code=%(s)s response_length=%(b)s referer=\"%(f)s\" process_id=%(p)s user_agent=\"%(a)s\"'", "web_api.wsgi"] 21 | -------------------------------------------------------------------------------- /web_api/README.md: -------------------------------------------------------------------------------- 1 | # web_api 2 | 3 | The web API for the Kodiak dashboard. 4 | 5 | ## dev 6 | 7 | ```console 8 | # install dependencies 9 | poetry config settings.virtualenvs.in-project true 10 | poetry install 11 | 12 | # copy & modify example .env file 13 | cp example.env .env 14 | 15 | s/dev 16 | 17 | s/test 18 | 19 | s/lint 20 | 21 | s/build 22 | 23 | 24 | # run production app server 25 | .venv/bin/gunicorn --bind 0.0.0.0:$PORT web_api.wsgi 26 | 27 | # ingest events for analysis (run continuously) 28 | ./manage.py ingest_events 29 | 30 | # aggregate events into chartable data (run on cron) 31 | ./manage.py aggregate_pull_request_activity 32 | 33 | # aggregate user activity (run on cron) 34 | ./manage.py aggregate_user_pull_request_activity 35 | ``` 36 | -------------------------------------------------------------------------------- /web_api/example.env: -------------------------------------------------------------------------------- 1 | DEBUG=1 2 | DATABASE_URL=postgresql://localhost:5432/kodiak_web_api 3 | REDIS_URL=redis://localhost:6379/ 4 | KODIAK_API_GITHUB_CLIENT_ID='client id from github' 5 | KODIAK_API_GITHUB_CLIENT_SECRET='secret from github' 6 | KODIAK_WEB_APP_URL='http://app.localhost.kodiakhq.com:3000' 7 | STRIPE_SECRET_KEY=sk_someStripeSecretKey 8 | STRIPE_PLAN_ID=plan_somePlanId 9 | STRIPE_ANNUAL_PLAN_ID=price_somePriceId 10 | STRIPE_WEBHOOK_SECRET=whsec_someWebhookSecret 11 | STRIPE_PUBLISHABLE_API_KEY=pk_test_someExampleStripeApiKey 12 | -------------------------------------------------------------------------------- /web_api/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main() -> None: 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web_api.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /web_api/s/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | ./.venv/bin/python ./manage.py runserver 5 | -------------------------------------------------------------------------------- /web_api/s/fmt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | set -eu 3 | ./.venv/bin/black . 4 | -------------------------------------------------------------------------------- /web_api/s/lint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | # format code 5 | if [[ $CI ]]; then 6 | ./.venv/bin/black --check . 7 | ./.venv/bin/ruff . 8 | else 9 | ./.venv/bin/black . 10 | ./.venv/bin/ruff . --fix 11 | fi 12 | 13 | # type check code 14 | ./.venv/bin/mypy . 15 | -------------------------------------------------------------------------------- /web_api/s/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | if [ "$CI" ]; then 5 | ./.venv/bin/pytest --cov=. --cov-report xml "$@" 6 | else 7 | ./.venv/bin/pytest "$@" 8 | fi 9 | -------------------------------------------------------------------------------- /web_api/s/upload-code-cov: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -ex 4 | 5 | bash <(curl -s https://codecov.io/bash) 6 | -------------------------------------------------------------------------------- /web_api/tox.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = 3 | ; formatting handled by black 4 | ; https://pycodestyle.readthedocs.io/en/latest/intro.html#error-codes 5 | ; https://github.com/ambv/black/issues/429 6 | E101, 7 | E111, 8 | E114, 9 | E115, 10 | E116, 11 | E117, 12 | E121, 13 | E122, 14 | E123, 15 | E124, 16 | E125, 17 | E126, 18 | E127, 19 | E128, 20 | E129, 21 | E131, 22 | E133, 23 | E2, 24 | E3, 25 | E5, 26 | E701, 27 | E702, 28 | E703, 29 | E704, 30 | W1, 31 | W2, 32 | W3, 33 | W503, 34 | W504, 35 | -------------------------------------------------------------------------------- /web_api/typings/zstandard.pyi: -------------------------------------------------------------------------------- 1 | class ZstdDecompressor: 2 | def decompress(self, data: bytes) -> bytes: ... 3 | -------------------------------------------------------------------------------- /web_api/web_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/web_api/web_api/__init__.py -------------------------------------------------------------------------------- /web_api/web_api/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.asgi import get_asgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web_api.settings") 6 | 7 | application = get_asgi_application() 8 | -------------------------------------------------------------------------------- /web_api/web_api/auth.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import wraps 4 | from typing import Any, TypeVar, Union, cast 5 | 6 | from django.http import HttpRequest, HttpResponse 7 | from typing_extensions import Protocol 8 | 9 | from web_api.exceptions import AuthenticationRequired 10 | from web_api.models import AnonymousUser, User 11 | 12 | 13 | class AuthedHttpRequest(HttpRequest): 14 | user: User # type: ignore [assignment] 15 | 16 | 17 | class RequestHandler1(Protocol): 18 | def __call__(self, request: AuthedHttpRequest) -> HttpResponse: 19 | ... 20 | 21 | 22 | class RequestHandler2(Protocol): 23 | def __call__(self, request: AuthedHttpRequest, __arg1: Any) -> HttpResponse: 24 | ... 25 | 26 | 27 | RequestHandler = Union[RequestHandler1, RequestHandler2] 28 | 29 | 30 | # Verbose bound arg due to limitations of Python typing. 31 | # see: https://github.com/python/mypy/issues/5876 32 | _F = TypeVar("_F", bound=RequestHandler) 33 | 34 | 35 | def login_required(view_func: _F) -> _F: 36 | @wraps(view_func) 37 | def wrapped_view( 38 | request: AuthedHttpRequest, *args: object, **kwargs: object 39 | ) -> HttpResponse: 40 | if request.user.is_authenticated: 41 | return view_func(request, *args, **kwargs) 42 | raise AuthenticationRequired 43 | 44 | return cast(_F, wrapped_view) 45 | 46 | 47 | def get_user(request: HttpRequest) -> User: 48 | """ 49 | Get a `User` from the request. If the user cannot be found we return `AnonymousUser`. 50 | 51 | Modified from: https://github.com/django/django/blob/6e99585c19290fb9bec502cac8210041fdb28484/django/contrib/auth/middleware.py#L9-L12 52 | """ 53 | if not hasattr(request, "_cached_user"): 54 | user = None 55 | try: 56 | user_id = request.session["user_id"] 57 | except KeyError: 58 | pass 59 | else: 60 | user = User.objects.filter(id=user_id).first() 61 | request._cached_user = user or AnonymousUser() # type: ignore [attr-defined] 62 | return request._cached_user # type: ignore [attr-defined, no-any-return] 63 | 64 | 65 | def login(user: User, request: HttpRequest) -> None: 66 | """ 67 | https://github.com/django/django/blob/6e99585c19290fb9bec502cac8210041fdb28484/django/contrib/auth/__init__.py#L86-L131 68 | """ 69 | request.session["user_id"] = str(user.id) 70 | request.user = user # type: ignore [assignment] 71 | 72 | 73 | def logout(request: HttpRequest) -> None: 74 | """ 75 | https://github.com/django/django/blob/6e99585c19290fb9bec502cac8210041fdb28484/django/contrib/auth/__init__.py#L134-L148 76 | """ 77 | request.session.flush() 78 | request.user = AnonymousUser() # type: ignore [assignment] 79 | -------------------------------------------------------------------------------- /web_api/web_api/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from web_api.testutils import TestClient 4 | 5 | 6 | @pytest.fixture 7 | def client() -> TestClient: 8 | return TestClient() 9 | -------------------------------------------------------------------------------- /web_api/web_api/event_ingestion.py: -------------------------------------------------------------------------------- 1 | """ 2 | Remove GitHub webhook events from Redis and store them in Postgres for analysis. 3 | 4 | This script should run constantly. 5 | 6 | The web api uses webhook events to calculate and display metrics about kodiak 7 | activity and determine usage. The Kodiak GitHub Bot accepts GitHub webhooks and 8 | forwards a selection of event types that we care about. The Redis queue is 9 | bounded at 10000 items, so if we have time to recover from downtime/restarts. 10 | """ 11 | 12 | import json 13 | import logging 14 | import os 15 | import time 16 | 17 | import redis 18 | import zstandard as zstd 19 | 20 | from web_api.models import GitHubEvent 21 | from web_api.utils import GracefulTermination 22 | 23 | logger = logging.getLogger(__name__) 24 | 25 | # events that we want to store in Postgres. Discard anything else. 26 | INTERESTING_EVENTS = {"pull_request", "pull_request_review", "pull_request_comment"} 27 | 28 | 29 | def ingest_events() -> None: 30 | """ 31 | Pull webhook events off the queue and insert them into Postgres to calculate 32 | usage statistics. 33 | """ 34 | r = redis.Redis.from_url(os.environ["REDIS_URL"]) 35 | while True: 36 | time.sleep(0) 37 | # we don't want to lose events when we terminate the process, so we 38 | # handle SIGINT and SIGTERM gracefully. We use a short timeout of Redis 39 | # BLPOP so we don't have to wait too long. 40 | with GracefulTermination(): 41 | logger.info("block for event") 42 | res = r.blpop("kodiak:webhook_event", timeout=5) 43 | if res is None: 44 | # if res is None we likely hit the timeout. 45 | continue 46 | _, event_compressed = res 47 | 48 | logger.info("process event") 49 | dctx = zstd.ZstdDecompressor() 50 | decompressed = dctx.decompress(event_compressed) 51 | event = json.loads(decompressed) 52 | 53 | event_name = event["event_name"] 54 | if event_name in INTERESTING_EVENTS: 55 | payload = event["payload"] 56 | GitHubEvent.objects.create(event_name=event_name, payload=payload) 57 | -------------------------------------------------------------------------------- /web_api/web_api/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class ApiException(Exception): 5 | code: int = 500 6 | message: str = "Internal Server Error" 7 | 8 | def __init__(self, message: Optional[str] = None, code: Optional[int] = None): 9 | if message is not None: 10 | self.message = message 11 | if code is not None: 12 | self.code = code 13 | 14 | 15 | class BadRequest(ApiException): 16 | code = 400 17 | message = "Bad Request" 18 | 19 | 20 | class AuthenticationRequired(ApiException): 21 | code = 401 22 | message = "Authentication Required" 23 | 24 | 25 | class PermissionDenied(ApiException): 26 | code = 403 27 | message = "Permission Denied" 28 | 29 | 30 | class UnprocessableEntity(ApiException): 31 | code = 422 32 | message = "Unprocessable Entity" 33 | -------------------------------------------------------------------------------- /web_api/web_api/http.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import django.http 4 | import pydantic 5 | from django.core.serializers.json import DjangoJSONEncoder 6 | 7 | 8 | class PydanticJsonEncoder(DjangoJSONEncoder): 9 | """ 10 | JSON encoder with Pydantic support. 11 | """ 12 | 13 | def default(self, o: object) -> Any: 14 | if isinstance(o, pydantic.BaseModel): 15 | return o.dict() 16 | return super().default(o) 17 | 18 | 19 | class JsonResponse(django.http.JsonResponse): 20 | """ 21 | JSON response with Pydantic support. 22 | """ 23 | 24 | def __init__(self, data: Any, **kwargs: Any): 25 | super().__init__(data, encoder=PydanticJsonEncoder, safe=False, **kwargs) 26 | -------------------------------------------------------------------------------- /web_api/web_api/management/commands/aggregate_pull_request_activity.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from web_api.models import PullRequestActivity 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Aggregate GitHubEvents into into PullRequestActivity" 8 | 9 | def handle(self, *args: object, **options: object) -> None: 10 | PullRequestActivity.aggregate_events() 11 | -------------------------------------------------------------------------------- /web_api/web_api/management/commands/aggregate_user_pull_request_activity.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from web_api.models import UserPullRequestActivity 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Generate User pull request activity analytics" 8 | 9 | def handle(self, *args: object, **options: object) -> None: 10 | UserPullRequestActivity.generate() 11 | -------------------------------------------------------------------------------- /web_api/web_api/management/commands/ingest_events.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand 2 | 3 | from web_api.event_ingestion import ingest_events 4 | 5 | 6 | class Command(BaseCommand): 7 | help = "Ingest webhook events into Postgres" 8 | 9 | def handle(self, *args: object, **options: object) -> None: 10 | ingest_events() 11 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-06 00:08 2 | 3 | import uuid 4 | from typing import Any, List 5 | 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | initial = True 11 | 12 | dependencies: List[Any] = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="User", 17 | fields=[ 18 | ("created_at", models.DateTimeField(auto_now_add=True)), 19 | ("modified_at", models.DateTimeField(auto_now=True)), 20 | ( 21 | "id", 22 | models.UUIDField( 23 | default=uuid.uuid4, primary_key=True, serialize=False 24 | ), 25 | ), 26 | ("github_id", models.IntegerField()), 27 | ("github_login", models.CharField(max_length=255)), 28 | ("github_access_token", models.CharField(max_length=255)), 29 | ], 30 | options={ 31 | "db_table": "user", 32 | }, 33 | ), 34 | ] 35 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0002_githubevent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-09 17:44 2 | 3 | import uuid 4 | 5 | import django.contrib.postgres.fields.jsonb 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("web_api", "0001_initial"), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="GitHubEvent", 17 | fields=[ 18 | ( 19 | "id", 20 | models.UUIDField( 21 | default=uuid.uuid4, primary_key=True, serialize=False 22 | ), 23 | ), 24 | ("created_at", models.DateTimeField(auto_now_add=True)), 25 | ("modified_at", models.DateTimeField(auto_now=True)), 26 | ("event_name", models.CharField(db_index=True, max_length=255)), 27 | ( 28 | "payload", 29 | django.contrib.postgres.fields.jsonb.JSONField(default=dict), 30 | ), 31 | ], 32 | options={ 33 | "db_table": "github_event", 34 | }, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0003_auto_20200215_1733.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-15 17:33 2 | 3 | import uuid 4 | 5 | import django.contrib.postgres.fields.jsonb 6 | import django.db.models.deletion 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("web_api", "0002_githubevent"), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Account", 18 | fields=[ 19 | ( 20 | "id", 21 | models.UUIDField( 22 | default=uuid.uuid4, primary_key=True, serialize=False 23 | ), 24 | ), 25 | ("created_at", models.DateTimeField(auto_now_add=True)), 26 | ("modified_at", models.DateTimeField(auto_now=True)), 27 | ("github_id", models.IntegerField(unique=True)), 28 | ("github_account_id", models.IntegerField(unique=True)), 29 | ("github_account_login", models.CharField(max_length=255, unique=True)), 30 | ( 31 | "github_account_type", 32 | models.CharField( 33 | choices=[("User", "User"), ("Organization", "Organization")], 34 | max_length=255, 35 | ), 36 | ), 37 | ( 38 | "payload", 39 | django.contrib.postgres.fields.jsonb.JSONField(default=dict), 40 | ), 41 | ], 42 | options={ 43 | "db_table": "account", 44 | }, 45 | ), 46 | migrations.AlterField( 47 | model_name="user", 48 | name="github_id", 49 | field=models.IntegerField(unique=True), 50 | ), 51 | migrations.AlterField( 52 | model_name="user", 53 | name="github_login", 54 | field=models.CharField(max_length=255, unique=True), 55 | ), 56 | migrations.CreateModel( 57 | name="AccountMembership", 58 | fields=[ 59 | ( 60 | "id", 61 | models.UUIDField( 62 | default=uuid.uuid4, primary_key=True, serialize=False 63 | ), 64 | ), 65 | ("created_at", models.DateTimeField(auto_now_add=True)), 66 | ("modified_at", models.DateTimeField(auto_now=True)), 67 | ( 68 | "account", 69 | models.ForeignKey( 70 | on_delete=django.db.models.deletion.CASCADE, 71 | related_name="memberships", 72 | to="web_api.Account", 73 | ), 74 | ), 75 | ( 76 | "user", 77 | models.ForeignKey( 78 | on_delete=django.db.models.deletion.CASCADE, 79 | related_name="memberships", 80 | to="web_api.User", 81 | ), 82 | ), 83 | ], 84 | options={ 85 | "db_table": "account_membership", 86 | }, 87 | ), 88 | ] 89 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0004_auto_20200215_2015.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-15 20:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0003_auto_20200215_1733"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="account", 14 | old_name="github_id", 15 | new_name="github_installation_id", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0005_auto_20200215_2156.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-15 21:56 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0004_auto_20200215_2015"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="github_account_id", 15 | field=models.IntegerField( 16 | help_text="GitHub ID for account with installation.", unique=True 17 | ), 18 | ), 19 | migrations.AlterField( 20 | model_name="account", 21 | name="github_account_login", 22 | field=models.CharField( 23 | help_text="GitHub username for account with installation.", 24 | max_length=255, 25 | unique=True, 26 | ), 27 | ), 28 | migrations.AlterField( 29 | model_name="account", 30 | name="github_installation_id", 31 | field=models.IntegerField( 32 | help_text="GitHub App Installation ID.", unique=True 33 | ), 34 | ), 35 | migrations.AlterField( 36 | model_name="user", 37 | name="github_access_token", 38 | field=models.CharField( 39 | help_text="OAuth token for the GitHub user.", max_length=255 40 | ), 41 | ), 42 | migrations.AlterField( 43 | model_name="user", 44 | name="github_id", 45 | field=models.IntegerField( 46 | help_text="GitHub ID of the GitHub user account.", unique=True 47 | ), 48 | ), 49 | migrations.AlterField( 50 | model_name="user", 51 | name="github_login", 52 | field=models.CharField( 53 | help_text="GitHub username of the GitHub account.", 54 | max_length=255, 55 | unique=True, 56 | ), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0006_remove_account_payload.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-16 03:02 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0005_auto_20200215_2156"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="account", 14 | name="payload", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0007_auto_20200217_0345.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-17 03:45 2 | 3 | import uuid 4 | 5 | from django.contrib.postgres.operations import CreateExtension 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("web_api", "0006_remove_account_payload"), 12 | ] 13 | 14 | operations = [ 15 | CreateExtension("uuid-ossp"), 16 | migrations.CreateModel( 17 | name="PullRequestActivity", 18 | fields=[ 19 | ( 20 | "id", 21 | models.UUIDField( 22 | default=uuid.uuid4, primary_key=True, serialize=False 23 | ), 24 | ), 25 | ("created_at", models.DateTimeField(auto_now_add=True)), 26 | ("modified_at", models.DateTimeField(auto_now=True)), 27 | ("date", models.DateField(db_index=True)), 28 | ("total_opened", models.IntegerField()), 29 | ("total_merged", models.IntegerField()), 30 | ("total_closed", models.IntegerField()), 31 | ("kodiak_approved", models.IntegerField()), 32 | ("kodiak_merged", models.IntegerField()), 33 | ("kodiak_updated", models.IntegerField()), 34 | ("github_installation_id", models.IntegerField(db_index=True)), 35 | ], 36 | options={ 37 | "db_table": "pull_request_activity", 38 | }, 39 | ), 40 | migrations.AddConstraint( 41 | model_name="pullrequestactivity", 42 | constraint=models.UniqueConstraint( 43 | fields=("date", "github_installation_id"), 44 | name="unique_pull_request_activity", 45 | ), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0008_payload_installation_id_idx.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-17 04:54 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | atomic = False 8 | 9 | dependencies = [ 10 | ("web_api", "0007_auto_20200217_0345"), 11 | ] 12 | 13 | operations = [ 14 | migrations.RunSQL( 15 | "CREATE INDEX CONCURRENTLY payload_installation_id_idx on github_event (((payload -> 'installation' ->> 'id')::integer))", 16 | reverse_sql="DROP INDEX IF EXISTS payload_installation_id_idx", 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0009_pullrequestactivityprogress.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-17 23:59 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("web_api", "0008_payload_installation_id_idx"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="PullRequestActivityProgress", 16 | fields=[ 17 | ( 18 | "id", 19 | models.UUIDField( 20 | default=uuid.uuid4, primary_key=True, serialize=False 21 | ), 22 | ), 23 | ("created_at", models.DateTimeField(auto_now_add=True)), 24 | ("modified_at", models.DateTimeField(auto_now=True)), 25 | ( 26 | "min_date", 27 | models.DateField( 28 | help_text="Date we should use as our minimum date for future aggregation jobs. Anything before this date is 'locked'." 29 | ), 30 | ), 31 | ], 32 | options={ 33 | "db_table": "pull_request_activity_progress", 34 | }, 35 | ), 36 | ] 37 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0010_auto_20200220_0116.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-02-20 01:16 2 | 3 | import uuid 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("web_api", "0009_pullrequestactivityprogress"), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name="UserPullRequestActivity", 16 | fields=[ 17 | ( 18 | "id", 19 | models.UUIDField( 20 | default=uuid.uuid4, primary_key=True, serialize=False 21 | ), 22 | ), 23 | ("created_at", models.DateTimeField(auto_now_add=True)), 24 | ("modified_at", models.DateTimeField(auto_now=True)), 25 | ("github_installation_id", models.IntegerField(db_index=True)), 26 | ( 27 | "github_repository_name", 28 | models.CharField(db_index=True, max_length=255), 29 | ), 30 | ("github_pull_request_number", models.IntegerField(db_index=True)), 31 | ("github_user_login", models.CharField(db_index=True, max_length=255)), 32 | ("github_user_id", models.IntegerField(db_index=True)), 33 | ("is_private_repository", models.BooleanField(db_index=True)), 34 | ("activity_date", models.DateField(db_index=True)), 35 | ], 36 | options={ 37 | "db_table": "user_pull_request_activity", 38 | }, 39 | ), 40 | migrations.CreateModel( 41 | name="UserPullRequestActivityProgress", 42 | fields=[ 43 | ( 44 | "id", 45 | models.UUIDField( 46 | default=uuid.uuid4, primary_key=True, serialize=False 47 | ), 48 | ), 49 | ("created_at", models.DateTimeField(auto_now_add=True)), 50 | ("modified_at", models.DateTimeField(auto_now=True)), 51 | ( 52 | "min_date", 53 | models.DateTimeField( 54 | db_index=True, 55 | help_text="Date we should use as our minimum date for future aggregation jobs. Anything before this date is 'locked'.", 56 | ), 57 | ), 58 | ], 59 | options={ 60 | "db_table": "user_pull_request_activity_progress", 61 | }, 62 | ), 63 | migrations.AddConstraint( 64 | model_name="userpullrequestactivity", 65 | constraint=models.UniqueConstraint( 66 | fields=( 67 | "github_installation_id", 68 | "github_repository_name", 69 | "github_pull_request_number", 70 | "github_user_id", 71 | "activity_date", 72 | ), 73 | name="unique_user_pull_request_activity", 74 | ), 75 | ), 76 | ] 77 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0011_accountmembership_role.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-08 19:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0010_auto_20200220_0116"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="accountmembership", 14 | name="role", 15 | field=models.CharField( 16 | choices=[("admin", "Admin"), ("member", "Member")], 17 | default="member", 18 | help_text="User's GitHub-defined role for the associated account.", 19 | max_length=255, 20 | ), 21 | preserve_default=False, 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0012_auto_20200308_2254.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-08 22:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0011_accountmembership_role"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddConstraint( 13 | model_name="accountmembership", 14 | constraint=models.CheckConstraint( 15 | check=models.Q(role__in=["admin", "member"]), name="role_valid" 16 | ), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0013_auto_20200310_0412.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-10 04:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0012_auto_20200308_2254"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="userpullrequestactivity", 14 | name="opened_pull_request", 15 | field=models.BooleanField(db_index=True, default=False), 16 | preserve_default=False, 17 | ), 18 | migrations.AddConstraint( 19 | model_name="account", 20 | constraint=models.CheckConstraint( 21 | check=models.Q(github_account_type__in=["User", "Organization"]), 22 | name="github_account_type_valid", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0015_remove_stripecustomerinformation_customer_delinquent.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-03-24 03:18 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0014_auto_20200323_0159"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="stripecustomerinformation", 14 | name="customer_delinquent", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0016_auto_20200405_1511.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-04-05 15:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0015_remove_stripecustomerinformation_customer_delinquent"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="stripecustomerinformation", 14 | name="payment_method_card_exp_month", 15 | field=models.CharField( 16 | help_text="Two-digit number representing the card’s expiration month.", 17 | max_length=255, 18 | null=True, 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="stripecustomerinformation", 23 | name="payment_method_card_exp_year", 24 | field=models.CharField( 25 | help_text="Four-digit number representing the card’s expiration year.", 26 | max_length=255, 27 | null=True, 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0017_account_trial_email.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-04-05 19:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0016_auto_20200405_1511"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="account", 14 | name="trial_email", 15 | field=models.CharField(blank=True, max_length=255), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0018_auto_20200502_1849.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-05-02 18:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0017_account_trial_email"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="stripecustomerinformation", 14 | name="payment_method_id", 15 | field=models.CharField( 16 | db_index=True, 17 | help_text="Unique identifier for Stripe PaymentMethod object.", 18 | max_length=255, 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="stripecustomerinformation", 23 | name="plan_id", 24 | field=models.CharField( 25 | db_index=True, 26 | help_text="Unique identifier for Stripe Plan object.", 27 | max_length=255, 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0019_auto_20200610_0006.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-06-10 00:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0018_auto_20200502_1849"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="account", 14 | name="stripe_plan_id", 15 | field=models.CharField( 16 | help_text="Stripe plan_id to use when creating subscription. Overrides settings.STRIPE_PLAN_ID if provided. We only need to set this when we provide a custom plan for a given user.", 17 | max_length=255, 18 | null=True, 19 | ), 20 | ), 21 | migrations.AddField( 22 | model_name="stripecustomerinformation", 23 | name="customer_currency", 24 | field=models.CharField( 25 | help_text="Three-letter ISO code for the currency the customer can be charged in for recurring billing purposes.", 26 | max_length=255, 27 | null=True, 28 | ), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0020_auto_20200613_2012.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-06-13 20:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0019_auto_20200610_0006"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="stripecustomerinformation", 14 | name="customer_address_city", 15 | field=models.CharField( 16 | help_text="City, district, suburb, town, or village.", 17 | max_length=255, 18 | null=True, 19 | ), 20 | ), 21 | migrations.AddField( 22 | model_name="stripecustomerinformation", 23 | name="customer_address_country", 24 | field=models.CharField( 25 | help_text="Two-letter country code (ISO 3166-1 alpha-2).", 26 | max_length=255, 27 | null=True, 28 | ), 29 | ), 30 | migrations.AddField( 31 | model_name="stripecustomerinformation", 32 | name="customer_address_line1", 33 | field=models.CharField( 34 | help_text="Address line 1 (e.g., street, PO Box, or company name).", 35 | max_length=255, 36 | null=True, 37 | ), 38 | ), 39 | migrations.AddField( 40 | model_name="stripecustomerinformation", 41 | name="customer_address_line2", 42 | field=models.CharField( 43 | help_text="Address line 2 (e.g., apartment, suite, unit, or building).", 44 | max_length=255, 45 | null=True, 46 | ), 47 | ), 48 | migrations.AddField( 49 | model_name="stripecustomerinformation", 50 | name="customer_address_postal_code", 51 | field=models.CharField( 52 | help_text="ZIP or postal code.", max_length=255, null=True 53 | ), 54 | ), 55 | migrations.AddField( 56 | model_name="stripecustomerinformation", 57 | name="customer_address_state", 58 | field=models.CharField( 59 | help_text="State, county, province, or region.", 60 | max_length=255, 61 | null=True, 62 | ), 63 | ), 64 | migrations.AddField( 65 | model_name="stripecustomerinformation", 66 | name="customer_name", 67 | field=models.CharField( 68 | help_text="The customer’s full name or business name.", 69 | max_length=255, 70 | null=True, 71 | ), 72 | ), 73 | ] 74 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0021_auto_20200617_1246.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-06-17 12:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0020_auto_20200613_2012"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="stripecustomerinformation", 14 | name="plan_interval", 15 | field=models.CharField( 16 | help_text="The frequency at which a subscription is billed. One of `day`, `week`, `month` or `year`.", 17 | max_length=255, 18 | null=True, 19 | ), 20 | ), 21 | migrations.AddField( 22 | model_name="stripecustomerinformation", 23 | name="plan_interval_count", 24 | field=models.IntegerField( 25 | help_text="The number of intervals (specified in the `interval` attribute) between subscription billings. For example, `interval=month` and `interval_count=3` bills every 3 months.", 26 | null=True, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0022_remove_account_stripe_plan_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-06-17 23:44 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0021_auto_20200617_1246"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="account", 14 | name="stripe_plan_id", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0023_account_limit_billing_access_to_owners.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-06-21 15:19 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0022_remove_account_stripe_plan_id"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="account", 14 | name="limit_billing_access_to_owners", 15 | field=models.BooleanField(default=False), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0024_auto_20200726_0316.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-07-26 03:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0023_account_limit_billing_access_to_owners"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="account", 14 | name="subscription_exempt", 15 | field=models.BooleanField( 16 | default=False, 17 | help_text="This account does not require a subscription. Potentially a GitHub Sponsor, Enterprise subscriber, non profit, etc.", 18 | ), 19 | ), 20 | migrations.AddField( 21 | model_name="account", 22 | name="subscription_exempt_message", 23 | field=models.TextField( 24 | help_text="Explanation for the subscription exemption to be displayed in the Usage & Billing page.", 25 | null=True, 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0025_auto_20200902_0052.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2020-09-02 00:52 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0024_auto_20200726_0316"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="account", 14 | name="contact_emails", 15 | field=models.TextField( 16 | blank=True, 17 | help_text="emails to contact about Kodiak issues. This is in addition to billing email from Stripe.", 18 | max_length=2000, 19 | ), 20 | ), 21 | migrations.AddConstraint( 22 | model_name="account", 23 | constraint=models.CheckConstraint( 24 | check=models.Q(contact_emails__length__lt=2000), 25 | name="contact_emails_max_length_2000", 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/0026_auto_20220322_0036.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.3 on 2022-03-22 00:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("web_api", "0025_auto_20200902_0052"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="account", 14 | name="created_at", 15 | field=models.DateTimeField(auto_now_add=True, db_index=True), 16 | ), 17 | migrations.AlterField( 18 | model_name="accountmembership", 19 | name="created_at", 20 | field=models.DateTimeField(auto_now_add=True, db_index=True), 21 | ), 22 | migrations.AlterField( 23 | model_name="githubevent", 24 | name="created_at", 25 | field=models.DateTimeField(auto_now_add=True, db_index=True), 26 | ), 27 | migrations.AlterField( 28 | model_name="pullrequestactivity", 29 | name="created_at", 30 | field=models.DateTimeField(auto_now_add=True, db_index=True), 31 | ), 32 | migrations.AlterField( 33 | model_name="pullrequestactivityprogress", 34 | name="created_at", 35 | field=models.DateTimeField(auto_now_add=True, db_index=True), 36 | ), 37 | migrations.AlterField( 38 | model_name="user", 39 | name="created_at", 40 | field=models.DateTimeField(auto_now_add=True, db_index=True), 41 | ), 42 | migrations.AlterField( 43 | model_name="userpullrequestactivity", 44 | name="created_at", 45 | field=models.DateTimeField(auto_now_add=True, db_index=True), 46 | ), 47 | migrations.AlterField( 48 | model_name="userpullrequestactivityprogress", 49 | name="created_at", 50 | field=models.DateTimeField(auto_now_add=True, db_index=True), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /web_api/web_api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/web_api/web_api/migrations/__init__.py -------------------------------------------------------------------------------- /web_api/web_api/patches.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from django.db.models.manager import BaseManager 4 | from django.db.models.query import QuerySet 5 | 6 | 7 | def patch_django() -> None: 8 | """ 9 | On Django versions < 3.1 we need to monkey patch the class get item 10 | method so that we can type QuerySet and Managers with a generic argument. 11 | """ 12 | for cls in (QuerySet, BaseManager): 13 | cls.__class_getitem__ = classmethod(lambda cls, *args, **kwargs: cls) # type: ignore [attr-defined] 14 | -------------------------------------------------------------------------------- /web_api/web_api/test_analytics_aggregator.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from pathlib import Path 4 | 5 | import pytest 6 | from django.core.management import call_command 7 | from django.utils.timezone import make_aware 8 | 9 | from web_api.models import GitHubEvent, PullRequestActivity, PullRequestActivityProgress 10 | 11 | FIXTURES = Path(__file__).parent / "tests" / "fixtures" 12 | 13 | 14 | @pytest.fixture 15 | def pull_request_kodiak_updated() -> None: 16 | event = GitHubEvent.objects.create( 17 | event_name="pull_request", 18 | payload=json.load((FIXTURES / "pull_request_kodiak_updated.json").open()), 19 | ) 20 | event.created_at = make_aware(datetime.datetime(2020, 2, 13)) # noqa: DTZ001 21 | event.save() 22 | 23 | 24 | @pytest.mark.django_db 25 | def test_analytics_aggregator(pull_request_kodiak_updated: object) -> None: 26 | assert PullRequestActivityProgress.objects.count() == 0 27 | assert PullRequestActivity.objects.count() == 0 28 | call_command("aggregate_pull_request_activity") 29 | assert PullRequestActivityProgress.objects.count() == 1 30 | assert PullRequestActivity.objects.count() == 1 31 | pull_request_activity = PullRequestActivity.objects.get() 32 | pull_request_activity_progress = PullRequestActivityProgress.objects.get() 33 | 34 | assert ( 35 | pull_request_activity_progress.min_date == datetime.date.today() # noqa: DTZ011 36 | ) 37 | assert pull_request_activity.total_opened == 0 38 | assert pull_request_activity.total_merged == 0 39 | assert pull_request_activity.total_closed == 0 40 | 41 | assert pull_request_activity.kodiak_approved == 0 42 | assert pull_request_activity.kodiak_merged == 0 43 | assert pull_request_activity.kodiak_updated == 1 44 | 45 | 46 | @pytest.mark.django_db 47 | def test_analytics_aggregator_min_date(pull_request_kodiak_updated: object) -> None: 48 | PullRequestActivityProgress.objects.create(min_date=datetime.date(2020, 2, 10)) 49 | PullRequestActivityProgress.objects.create( 50 | min_date=datetime.date.today() # noqa: DTZ011 51 | ) 52 | assert PullRequestActivity.objects.count() == 0 53 | call_command("aggregate_pull_request_activity") 54 | assert PullRequestActivity.objects.count() == 0 55 | -------------------------------------------------------------------------------- /web_api/web_api/test_merge_queues.py: -------------------------------------------------------------------------------- 1 | from web_api.merge_queues import QueueInfo, queue_info_from_name 2 | 3 | 4 | def test_queue_info_from_name() -> None: 5 | assert queue_info_from_name("merge_queue:11256551.sbdchd/squawk/main") == QueueInfo( 6 | "sbdchd", "squawk", "main" 7 | ) 8 | assert queue_info_from_name( 9 | "merge_queue:11256551.sbdchd/squawk/main.test.foo" 10 | ) == QueueInfo("sbdchd", "squawk", "main.test.foo") 11 | assert queue_info_from_name( 12 | "merge_queue:11256551.sbdchd/squawk/chris/main.test.foo" 13 | ) == QueueInfo("sbdchd", "squawk", "chris/main.test.foo") 14 | -------------------------------------------------------------------------------- /web_api/web_api/test_middleware.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from web_api.testutils import TestClient as Client 4 | 5 | 6 | @pytest.mark.django_db 7 | def test_health_check_middleware(client: Client) -> None: 8 | """ 9 | smoke test for the health check endpoints 10 | """ 11 | res = client.get("/healthz") 12 | assert res.status_code == 200 13 | res = client.get("/readiness") 14 | assert res.status_code == 200 15 | -------------------------------------------------------------------------------- /web_api/web_api/test_stripe_customer_info.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from web_api.models import StripeCustomerInformation 6 | 7 | 8 | @pytest.mark.django_db 9 | def test_expired(mocker: Any) -> None: 10 | """ 11 | The subscription should be expired if past the period end. We give a grace 12 | period of two days, so to be expired we need to be three days pass the 13 | expiration period. 14 | """ 15 | ONE_DAY_SEC = 60 * 60 * 24 16 | period_start = 1650581784 17 | period_end = 1655765784 + ONE_DAY_SEC * 30 # start plus one month. 18 | mocker.patch("web_api.models.time.time", return_value=period_end + ONE_DAY_SEC * 3) 19 | stripe_customer_info = StripeCustomerInformation.objects.create( 20 | customer_id="cus_Ged32s2xnx12", 21 | subscription_id="sub_Gu1xedsfo1", 22 | plan_id="plan_G2df31A4G5JzQ", 23 | payment_method_id="pm_22dldxf3", 24 | customer_email="accounting@acme-corp.com", 25 | customer_balance=0, 26 | customer_created=1585781308, 27 | payment_method_card_brand="mastercard", 28 | payment_method_card_exp_month="03", 29 | payment_method_card_exp_year="32", 30 | payment_method_card_last4="4242", 31 | plan_amount=499, 32 | subscription_quantity=3, 33 | subscription_start_date=1585781784, 34 | subscription_current_period_start=period_start, 35 | subscription_current_period_end=period_end, 36 | ) 37 | 38 | assert stripe_customer_info.expired is True 39 | 40 | 41 | @pytest.mark.django_db 42 | def test_expired_inside_grace_period(mocker: Any) -> None: 43 | """ 44 | Inside the grace period (two days) we will not show the subscription as 45 | expired. 46 | """ 47 | ONE_DAY_SEC = 60 * 60 * 24 48 | period_start = 1650581784 49 | period_end = 1655765784 + 30 * ONE_DAY_SEC # start plus one month. 50 | mocker.patch("web_api.models.time.time", return_value=period_end + ONE_DAY_SEC) 51 | stripe_customer_info = StripeCustomerInformation.objects.create( 52 | customer_id="cus_Ged32s2xnx12", 53 | subscription_id="sub_Gu1xedsfo1", 54 | plan_id="plan_G2df31A4G5JzQ", 55 | payment_method_id="pm_22dldxf3", 56 | customer_email="accounting@acme-corp.com", 57 | customer_balance=0, 58 | customer_created=1585781308, 59 | payment_method_card_brand="mastercard", 60 | payment_method_card_exp_month="03", 61 | payment_method_card_exp_year="32", 62 | payment_method_card_last4="4242", 63 | plan_amount=499, 64 | subscription_quantity=3, 65 | subscription_start_date=1585781784, 66 | subscription_current_period_start=period_start, 67 | subscription_current_period_end=period_end, 68 | ) 69 | 70 | assert stripe_customer_info.expired is False 71 | -------------------------------------------------------------------------------- /web_api/web_api/testutils.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from typing import Any, cast 3 | 4 | from django.conf import settings 5 | from django.http import HttpRequest, SimpleCookie 6 | from django.test.client import Client as DjangoTestClient 7 | 8 | from web_api import auth 9 | from web_api.models import User 10 | 11 | 12 | class TestClient(DjangoTestClient): 13 | def login(self, user: User) -> None: # type: ignore [override] 14 | engine = cast(Any, import_module(settings.SESSION_ENGINE)) 15 | 16 | # Create a fake request to store login details. 17 | request = HttpRequest() 18 | 19 | if self.session: 20 | request.session = self.session 21 | else: 22 | request.session = engine.SessionStore() 23 | auth.login(user, request) 24 | 25 | # Save the session values. 26 | request.session.save() 27 | 28 | # Set the cookie to represent the session. 29 | session_cookie = settings.SESSION_COOKIE_NAME 30 | self.cookies[session_cookie] = request.session.session_key 31 | cookie_data = { 32 | "max-age": None, 33 | "path": "/", 34 | "domain": settings.SESSION_COOKIE_DOMAIN, 35 | "secure": settings.SESSION_COOKIE_SECURE or None, 36 | "expires": None, 37 | } 38 | self.cookies[session_cookie].update(cookie_data) 39 | 40 | def logout(self) -> None: 41 | """Log out the user by removing the cookies and session object.""" 42 | request = HttpRequest() 43 | engine = cast(Any, import_module(settings.SESSION_ENGINE)) 44 | if self.session: 45 | request.session = self.session 46 | request.user = auth.get_user(request) # type: ignore [assignment] 47 | else: 48 | request.session = engine.SessionStore() 49 | auth.logout(request) 50 | self.cookies = SimpleCookie() 51 | -------------------------------------------------------------------------------- /web_api/web_api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from web_api import views 4 | 5 | urlpatterns = [ 6 | path("v1/oauth_login", views.oauth_login), 7 | path("v1/oauth_complete", views.oauth_complete), 8 | path("v1/logout", views.logout), 9 | path("v1/sync_accounts", views.sync_accounts), 10 | path("v1/t//usage_billing", views.usage_billing), 11 | path("v1/t//activity", views.activity), 12 | path("v1/t//current_account", views.current_account), 13 | path("v1/t//start_trial", views.start_trial), 14 | path("v1/t//update_subscription", views.update_subscription), 15 | path("v1/t//fetch_proration", views.fetch_proration), 16 | path("v1/t//cancel_subscription", views.cancel_subscription), 17 | path("v1/t//start_checkout", views.start_checkout), 18 | path("v1/t//modify_payment_details", views.modify_payment_details), 19 | path( 20 | "v1/t//update_stripe_customer_info", 21 | views.update_stripe_customer_info, 22 | ), 23 | path("v1/t//subscription_info", views.get_subscription_info), 24 | path( 25 | "v1/t//stripe_self_serve_redirect", 26 | views.redirect_to_stripe_self_serve_portal, 27 | ), 28 | path("v1/stripe_webhook", views.stripe_webhook_handler), 29 | path("v1/accounts", views.accounts), 30 | path("v1/ping", views.ping), 31 | path("v1/healthcheck", views.healthcheck), 32 | path("v1/debug_sentry", views.debug_sentry), 33 | ] 34 | -------------------------------------------------------------------------------- /web_api/web_api/user_activity_aggregator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | 4 | import django 5 | 6 | # fmt: off 7 | # must setup django before importing models 8 | os.environ["DJANGO_SETTINGS_MODULE"] = "web_api.settings" 9 | django.setup() 10 | # pylint: disable=wrong-import-position 11 | from web_api.models import UserPullRequestActivity # noqa:E402 isort:skip 12 | # fmt: on 13 | 14 | 15 | def main() -> None: 16 | UserPullRequestActivity.generate() 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /web_api/web_api/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import signal 3 | import sys 4 | from typing import Any 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | class GracefulTermination: 10 | """ 11 | Wait for inner scope to complete before exiting after receiving SIGINT or SIGTERM. 12 | 13 | source: https://stackoverflow.com/a/50174144 14 | """ 15 | 16 | killed = False 17 | old_sigint: Any = None 18 | old_sigterm: Any = None 19 | 20 | def _handler(self, signum: int, frame: object) -> None: 21 | logging.info("Received %s. Exiting gracefully.", signal.Signals(signum)) 22 | self.killed = True 23 | 24 | def __enter__(self) -> None: 25 | self.old_sigint = signal.signal(signal.SIGINT, self._handler) 26 | self.old_sigterm = signal.signal(signal.SIGTERM, self._handler) 27 | 28 | def __exit__(self, type: object, value: object, traceback: object) -> None: 29 | if self.killed: 30 | sys.exit(0) 31 | signal.signal(signal.SIGINT, self.old_sigint) 32 | signal.signal(signal.SIGTERM, self.old_sigterm) 33 | -------------------------------------------------------------------------------- /web_api/web_api/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web_api.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /web_ui/.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | -------------------------------------------------------------------------------- /web_ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | parserOptions: { 5 | sourceType: "module", 6 | project: "./tsconfig.json", 7 | ecmaFeatures: { 8 | jsx: true, 9 | }, 10 | }, 11 | plugins: ["html", "react", "react-hooks", "@typescript-eslint", "import"], 12 | extends: ["prettier"], 13 | settings: { 14 | react: { 15 | version: "detect", 16 | }, 17 | }, 18 | env: { 19 | browser: true, 20 | }, 21 | rules: { 22 | "react-hooks/rules-of-hooks": "error", 23 | "react-hooks/exhaustive-deps": "error", 24 | "react/jsx-no-useless-fragment": "error", 25 | "no-restricted-globals": [ 26 | "error", 27 | "close", 28 | "closed", 29 | "status", 30 | "name", 31 | "length", 32 | "origin", 33 | "event", 34 | ], 35 | "react/self-closing-comp": [ 36 | "error", 37 | { 38 | component: true, 39 | html: true, 40 | }, 41 | ], 42 | "import/no-duplicates": "error", 43 | "no-unneeded-ternary": ["error", { defaultAssignment: false }], 44 | "@typescript-eslint/no-non-null-assertion": "error", 45 | "@typescript-eslint/await-thenable": "error", 46 | "@typescript-eslint/no-for-in-array": "error", 47 | "@typescript-eslint/prefer-as-const": "error", 48 | "@typescript-eslint/prefer-reduce-type-parameter": "error", 49 | "init-declarations": ["error", "always"], 50 | "react/jsx-fragments": "error", 51 | "no-lonely-if": "error", 52 | "object-shorthand": ["error", "always"], 53 | "@typescript-eslint/consistent-type-assertions": [ 54 | "error", 55 | { 56 | assertionStyle: "never", 57 | }, 58 | ], 59 | "react/jsx-key": ["error", { checkFragmentShorthand: true }], 60 | "react/no-danger": "error", 61 | eqeqeq: ["error", "smart"], 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /web_ui/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /web_ui/.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | node_modules/ 3 | coverage/ 4 | .venv/ 5 | .mypy_cache/ 6 | .terraform/ 7 | .pytest_cache/ 8 | -------------------------------------------------------------------------------- /web_ui/.prettierrc.js: -------------------------------------------------------------------------------- 1 | // https://prettier.io/docs/en/options.html 2 | module.exports = { 3 | semi: false, 4 | useTabs: false, 5 | tabWidth: 2, 6 | singleQuote: false, 7 | trailingComma: "all", 8 | bracketSpacing: true, 9 | jsxBracketSameLine: true, 10 | arrowParens: "avoid", 11 | } 12 | -------------------------------------------------------------------------------- /web_ui/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /web_ui/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.4.0 as builder 2 | 3 | RUN mkdir -p /var/app 4 | 5 | WORKDIR /var/app 6 | 7 | COPY package.json yarn.lock /var/app/ 8 | 9 | RUN yarn install 10 | 11 | COPY . /var/app 12 | 13 | RUN node /var/app/scripts/build.js 14 | 15 | 16 | FROM nginx:1.13.8-alpine@sha256:c8ff0187cc75e1f5002c7ca9841cb191d33c4080f38140b9d6f07902ababbe66 17 | RUN mkdir -p /var/app/build 18 | COPY --from=builder /var/app/build /var/app/build 19 | COPY general_headers.conf /etc/nginx/headers.d/ 20 | COPY nginx.conf /etc/nginx/conf.d/ 21 | RUN rm /etc/nginx/conf.d/default.conf 22 | WORKDIR /var/app 23 | -------------------------------------------------------------------------------- /web_ui/README.md: -------------------------------------------------------------------------------- 1 | # web_ui 2 | 3 | The dashboard UI for kodiak. 4 | 5 | ## dev 6 | 7 | ```console 8 | # install dependencies 9 | yarn install 10 | 11 | # copy & modify example .env file 12 | cp example.env .env 13 | 14 | s/dev 15 | 16 | s/test 17 | 18 | s/lint 19 | 20 | s/build 21 | ``` 22 | -------------------------------------------------------------------------------- /web_ui/config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | // This is a custom Jest transformer turning style imports into empty objects. 2 | // http://facebook.github.io/jest/docs/en/webpack.html 3 | 4 | module.exports = { 5 | process() { 6 | return "module.exports = {};" 7 | }, 8 | getCacheKey() { 9 | // The output is always the same. 10 | return "cssTransform" 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /web_ui/config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | const path = require("path") 2 | const camelcase = require("camelcase") 3 | 4 | // This is a custom Jest transformer turning file imports into filenames. 5 | // http://facebook.github.io/jest/docs/en/webpack.html 6 | 7 | module.exports = { 8 | /** 9 | * @param {unknown} _src 10 | * @param {string} filename 11 | */ 12 | process(_src, filename) { 13 | const assetFilename = JSON.stringify(path.basename(filename)) 14 | 15 | if (filename.match(/\.svg$/)) { 16 | // Based on how SVGR generates a component name: 17 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 18 | const pascalCaseFilename = camelcase(path.parse(filename).name, { 19 | pascalCase: true, 20 | }) 21 | const componentName = `Svg${pascalCaseFilename}` 22 | return `const React = require('react'); 23 | module.exports = { 24 | __esModule: true, 25 | default: ${assetFilename}, 26 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 27 | return { 28 | $$typeof: Symbol.for('react.element'), 29 | type: 'svg', 30 | ref: ref, 31 | key: null, 32 | props: Object.assign({}, props, { 33 | children: ${assetFilename} 34 | }) 35 | }; 36 | }), 37 | };` 38 | } 39 | 40 | return `module.exports = ${assetFilename};` 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /web_ui/config/pnpTs.js: -------------------------------------------------------------------------------- 1 | const { resolveModuleName } = require("ts-pnp") 2 | 3 | /** 4 | * @param {{ 5 | * resolveModuleName: ( 6 | * moduleName: string, 7 | * containingFile: string, 8 | * options: import("typescript").CompilerOptions, 9 | * moduleResolutionHost: import("typescript").ModuleResolutionHost 10 | * ) => import("typescript").ResolvedTypeReferenceDirectiveWithFailedLookupLocations 11 | * }} typescript 12 | * @param {string} moduleName 13 | * @param {string} containingFile 14 | * @param {{}} compilerOptions 15 | * @param {import("typescript").ModuleResolutionHost} resolutionHost 16 | */ 17 | exports.resolveModuleName = ( 18 | typescript, 19 | moduleName, 20 | containingFile, 21 | compilerOptions, 22 | resolutionHost, 23 | ) => { 24 | return resolveModuleName( 25 | moduleName, 26 | containingFile, 27 | compilerOptions, 28 | resolutionHost, 29 | typescript.resolveModuleName, 30 | ) 31 | } 32 | 33 | /** 34 | * @param {{ 35 | * resolveTypeReferenceDirective: ( 36 | * moduleName: string, 37 | * containingFile: string, 38 | * options: import("typescript").CompilerOptions, 39 | * moduleResolutionHost: import("typescript").ModuleResolutionHost 40 | * ) => import("typescript").ResolvedTypeReferenceDirectiveWithFailedLookupLocations 41 | * }} typescript 42 | * @param {string} moduleName 43 | * @param {string} containingFile 44 | * @param {{}} compilerOptions 45 | * @param {import("typescript").ModuleResolutionHost} resolutionHost 46 | */ 47 | exports.resolveTypeReferenceDirective = ( 48 | typescript, 49 | moduleName, 50 | containingFile, 51 | compilerOptions, 52 | resolutionHost, 53 | ) => { 54 | return resolveModuleName( 55 | moduleName, 56 | containingFile, 57 | compilerOptions, 58 | resolutionHost, 59 | typescript.resolveTypeReferenceDirective, 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /web_ui/general_headers.conf: -------------------------------------------------------------------------------- 1 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#Directives 2 | add_header Content-Security-Policy "default-src 'self' https://sentry.io; connect-src https://*.kodiakhq.com https://*.sentry.io https://api.stripe.com https://checkout.stripe.com; script-src 'self' https://js.stripe.com; img-src * https://*.stripe.com; style-src 'self' 'unsafe-inline' https://js.stripe.com https://checkout.stripe.com; frame-ancestors 'none'; frame-src https://js.stripe.com https://hooks.stripe.com https://checkout.stripe.com; base-uri 'self'; form-action 'self'; report-uri https://sentry.io/api/3352104/security/?sentry_key=0012ad6693d042d1b57ac5f00918b3bd"; 3 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy 4 | add_header Referrer-Policy "strict-origin-when-cross-origin"; 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options 6 | add_header X-Content-Type-Options "nosniff"; 7 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options 8 | add_header X-Frame-Options "SAMEORIGIN"; 9 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection 10 | add_header X-XSS-Protection "1; mode=block"; 11 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security 12 | add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; 13 | -------------------------------------------------------------------------------- /web_ui/netlify.toml: -------------------------------------------------------------------------------- 1 | # see: https://docs.netlify.com/configure-builds/file-based-configuration/ 2 | [[redirects]] 3 | from = "/*" 4 | to = "/index.html" 5 | status = 200 6 | -------------------------------------------------------------------------------- /web_ui/nginx.conf: -------------------------------------------------------------------------------- 1 | # don't send nginx version in headers or error pages 2 | server_tokens off; 3 | 4 | # https://gist.github.com/simonw/1e0fdf2e9b8744b39bd7 5 | # https://nginx.org/en/docs/http/ngx_http_log_module.html#log_format 6 | log_format logfmt 'time_local="$time_local" ' 7 | 'remote_addr="$remote_addr" ' 8 | 'request="$request" ' 9 | 'status=$status ' 10 | 'body_bytes_sent=$body_bytes_sent ' 11 | 'request_time=$request_time ' 12 | 'upstream_response_time=$upstream_response_time ' 13 | 'request_id=$request_id ' 14 | 'forwarded_for="$http_x_forwarded_for" ' 15 | 'forwarded_proto="$http_x_forwarded_proto" ' 16 | 'http_referer="$http_referer" ' 17 | 'http_user_agent="$http_user_agent"'; 18 | 19 | # https://nginx.org/en/docs/http/ngx_http_gzip_module.html#gzip 20 | gzip on; 21 | gzip_comp_level 5; 22 | 23 | # https://github.com/h5bp/server-configs-nginx/blob/master/nginx.conf#L103 24 | # Don't compress anything that's already small and unlikely to shrink much 25 | # if at all (the default is 20 bytes, which is bad as that usually leads to 26 | # larger files after gzipping). 27 | # Default: 20 28 | gzip_min_length 256; 29 | 30 | # we don't compress media that already has compression built into the format 31 | gzip_types 32 | text/css 33 | text/xml 34 | text/plain 35 | image/svg+xml 36 | image/tiff 37 | application/javascript 38 | application/atom+xml 39 | application/rss+xml 40 | application/font-woff 41 | application/json 42 | application/xhtml+xml; 43 | 44 | # https://stackoverflow.com/a/33448739/3555105 45 | # http://nginx.org/en/docs/http/ngx_http_gzip_module.html#gzip_proxied 46 | gzip_proxied any; 47 | gzip_vary on; 48 | 49 | 50 | 51 | server { 52 | listen 80 default_server; 53 | listen [::]:80 default_server ipv6only=on; 54 | 55 | access_log /var/log/nginx/access.log logfmt; 56 | error_log /var/log/nginx/error.log; 57 | 58 | include headers.d/general_headers.conf; 59 | 60 | root /var/app/build; 61 | 62 | 63 | location / { 64 | include headers.d/general_headers.conf; 65 | # Ensure the browser doesn't cache the index.html. 66 | # Without cache-control headers, browsers use 67 | # heuristic caching. 68 | add_header Cache-Control "no-store"; 69 | 70 | # First attempt to serve request as file, then serve our index.html 71 | try_files $uri /index.html; 72 | } 73 | 74 | location /static/ { 75 | # Set maximum expiration time. By default, it's off. 76 | expires max; 77 | try_files $uri =404; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /web_ui/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chdsbd/kodiak/0065e640d6febdb08ea236f459c125990fd9d9cf/web_ui/public/favicon.ico -------------------------------------------------------------------------------- /web_ui/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Kodiak 🔮 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /web_ui/s/dev: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # configure react-scripts to open correct domain on start. Also configure API routing for JS bundle. 3 | # https://github.com/facebook/create-react-app/blob/8b0dd54c7a7488d46a43ff6d1c67a6b41c31feb1/packages/react-scripts/scripts/start.js#L61 4 | HOST=app.localhost.kodiakhq.com exec node scripts/start.js 5 | -------------------------------------------------------------------------------- /web_ui/s/fmt: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | yarn prettier --write '**/*.{js,jsx,ts,tsx,md,yml,json}' "$@" 4 | -------------------------------------------------------------------------------- /web_ui/s/lint: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | main() { 5 | if [ "$CI" ]; then 6 | yarn eslint '**/*.{ts,tsx,js,jsx}' 7 | yarn prettier --check '**/*.{js,jsx,ts,tsx,md,yml,json}' 8 | else 9 | yarn eslint '**/*.{ts,tsx,js,jsx}' --fix 10 | yarn prettier --write '**/*.{js,jsx,ts,tsx,md,yml,json}' 11 | fi 12 | yarn tslint --project . 13 | yarn tsc --noEmit 14 | } 15 | 16 | main "$@" 17 | -------------------------------------------------------------------------------- /web_ui/s/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec node scripts/test.js 3 | -------------------------------------------------------------------------------- /web_ui/s/typecheck: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -ex 3 | 4 | yarn tsc --noEmit "$@" 5 | -------------------------------------------------------------------------------- /web_ui/scripts/test.js: -------------------------------------------------------------------------------- 1 | // Do this as the first thing so that any code reading it knows the right env. 2 | process.env.BABEL_ENV = "test" 3 | process.env.NODE_ENV = "test" 4 | process.env.PUBLIC_URL = "" 5 | 6 | // Makes the script crash on unhandled rejections instead of silently 7 | // ignoring them. In the future, promise rejections that are not handled will 8 | // terminate the Node.js process with a non-zero exit code. 9 | process.on("unhandledRejection", err => { 10 | throw err 11 | }) 12 | 13 | // Ensure environment variables are read. 14 | require("../config/env") 15 | 16 | const jest = require("jest") 17 | const execSync = require("child_process").execSync 18 | let argv = process.argv.slice(2) 19 | 20 | function isInGitRepository() { 21 | try { 22 | execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }) 23 | return true 24 | } catch (e) { 25 | return false 26 | } 27 | } 28 | 29 | function isInMercurialRepository() { 30 | try { 31 | execSync("hg --cwd . root", { stdio: "ignore" }) 32 | return true 33 | } catch (e) { 34 | return false 35 | } 36 | } 37 | 38 | // Watch unless on CI or explicitly running all tests 39 | if ( 40 | !process.env.CI && 41 | argv.indexOf("--watchAll") === -1 && 42 | argv.indexOf("--watchAll=false") === -1 43 | ) { 44 | // https://github.com/facebook/create-react-app/issues/5210 45 | const hasSourceControl = isInGitRepository() || isInMercurialRepository() 46 | argv.push(hasSourceControl ? "--watch" : "--watchAll") 47 | } 48 | 49 | jest.run(argv) 50 | -------------------------------------------------------------------------------- /web_ui/src/auth.ts: -------------------------------------------------------------------------------- 1 | import { loginPath } from "./settings" 2 | import uuid from "uuid/v4" 3 | 4 | export function startLogin() { 5 | const url = new URL(location.href) 6 | url.pathname = loginPath 7 | const queryParams = new URLSearchParams(location.search) 8 | const redirectUri = queryParams.get("redirect") 9 | const state = JSON.stringify({ nonce: uuid(), redirect: redirectUri }) 10 | url.searchParams.set("state", state) 11 | localStorage.setItem("oauth_state", state) 12 | // eslint-disable-next-line no-restricted-globals 13 | location.href = String(url) 14 | } 15 | 16 | export function getOauthState() { 17 | return localStorage.getItem("oauth_state") || "" 18 | } 19 | 20 | export function getRedirectPath(x: string): string | undefined { 21 | try { 22 | // tslint:disable-next-line: no-unsafe-any 23 | const redirect = JSON.parse(x)["redirect"] 24 | if (typeof redirect === "string") { 25 | return redirect 26 | } 27 | } catch (_) { 28 | // pass 29 | } 30 | return undefined 31 | } 32 | -------------------------------------------------------------------------------- /web_ui/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { 3 | BrowserRouter as Router, 4 | Switch, 5 | Route, 6 | Redirect, 7 | } from "react-router-dom" 8 | import { Container } from "react-bootstrap" 9 | import { UsageBillingPage } from "./UsageBillingPage" 10 | import { LoginPage } from "./LoginPage" 11 | import { OAuthPage } from "./OAuthPage" 12 | import { AccountsPage } from "./AccountsPage" 13 | import { ActivityPage } from "./ActivityPage" 14 | import { Page } from "./Page" 15 | import { ErrorBoundary } from "./ErrorBoundary" 16 | import { NotFoundPage } from "./NotFoundPage" 17 | import { DebugSentryPage } from "./DebugSentryPage" 18 | 19 | export default function App() { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /web_ui/src/components/DebugSentryPage.tsx: -------------------------------------------------------------------------------- 1 | function throwError() { 2 | throw Error("Test exception for Sentry") 3 | } 4 | export function DebugSentryPage() { 5 | throwError() 6 | return null 7 | } 8 | -------------------------------------------------------------------------------- /web_ui/src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import * as Sentry from "@sentry/browser" 3 | 4 | export class ErrorBoundary extends React.Component<{}, { hasError: boolean }> { 5 | constructor(props: {}) { 6 | super(props) 7 | this.state = { hasError: false } 8 | } 9 | 10 | static getDerivedStateFromError(_error: Error) { 11 | // Update state so the next render will show the fallback UI. 12 | return { hasError: true } 13 | } 14 | 15 | componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 16 | Sentry.withScope(scope => { 17 | scope.setExtra("errorInfo", errorInfo) 18 | Sentry.captureException(error) 19 | }) 20 | } 21 | 22 | render() { 23 | if (this.state.hasError) { 24 | // You can render any custom fallback UI 25 | return

Something went wrong.

26 | } 27 | 28 | return this.props.children 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web_ui/src/components/Image.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface IImageProps { 4 | readonly url: string 5 | readonly size: number 6 | readonly alt: string 7 | readonly className: string 8 | } 9 | export function Image({ url, size, alt, className }: IImageProps) { 10 | return ( 11 | {alt} 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /web_ui/src/components/LoginPage.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { render } from "@testing-library/react" 3 | import { LoginPage } from "./LoginPage" 4 | 5 | describe("LoginPage", () => { 6 | test("snap smoke test", () => { 7 | const { container } = render() 8 | 9 | expect(container).toMatchSnapshot() 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /web_ui/src/components/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { docsUrl, helpUrl, installUrl } from "../settings" 3 | import { startLogin } from "../auth" 4 | 5 | export function LoginPage() { 6 | return ( 7 |
8 |
11 |
12 | favicon 19 |

Kodiak

20 |
21 | 22 |
23 | 26 |
27 | 28 |

29 | Install | Docs |{" "} 30 | Help 31 |

32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /web_ui/src/components/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Container } from "react-bootstrap" 3 | 4 | export function NotFoundPage() { 5 | return ( 6 | 7 |

404 – Not Found

8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /web_ui/src/components/OAuthPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react" 2 | import { startLogin, getOauthState, getRedirectPath } from "../auth" 3 | import { useLocation, useHistory } from "react-router-dom" 4 | import { Current } from "../world" 5 | import { Button } from "react-bootstrap" 6 | 7 | export function OAuthPage() { 8 | const location = useLocation() 9 | const history = useHistory() 10 | const [error, setError] = useState() 11 | // This isn't supported in IE, but we're not going to support IE anyway. 12 | const queryParams = new URLSearchParams(location.search) 13 | const code = queryParams.get("code") || "" 14 | const serverState = queryParams.get("state") || "" 15 | const clientState = getOauthState() 16 | useEffect(() => { 17 | Current.api.loginUser({ code, serverState, clientState }).then(res => { 18 | if (res.ok) { 19 | // navigate to redirect path if available, otherwise redirect to root page. 20 | const redirectPath = getRedirectPath(clientState) || "/" 21 | history.push(redirectPath) 22 | return 23 | } else { 24 | setError(`${res.error} – ${res.error_description}`) 25 | } 26 | }) 27 | }, [clientState, code, history, serverState]) 28 | return ( 29 |
30 |
33 |
34 | favicon 41 |

Kodiak

42 |
43 | 44 | {!error ? ( 45 |

Logging in...

46 | ) : ( 47 |

48 | 49 | Login failure 50 |
51 |
{" "} 52 | {error} 53 |

54 | )} 55 | 56 |

57 | {error && ( 58 | 62 | )} 63 | Return to Login 64 |

65 |
66 |
67 | ) 68 | } 69 | -------------------------------------------------------------------------------- /web_ui/src/components/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { SideBarNav } from "./SideBarNav" 3 | import { Container } from "react-bootstrap" 4 | import { ErrorBoundary } from "./ErrorBoundary" 5 | import { SubscriptionAlert } from "./SubscriptionAlert" 6 | 7 | interface IPageProps { 8 | readonly children: React.ReactNode 9 | } 10 | export function Page({ children }: IPageProps) { 11 | return ( 12 |
13 |
14 |
15 | 16 |
17 | 18 |
19 | 20 | 21 | {children} 22 | 23 |
24 |
25 |
26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /web_ui/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Spinner as BootstrapSpinner } from "react-bootstrap" 3 | 4 | export function Spinner() { 5 | return ( 6 |
7 | 8 | Loading... 9 | 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /web_ui/src/components/SubscriptionAlert.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Link } from "react-router-dom" 3 | import { Alert } from "react-bootstrap" 4 | import { Current } from "src/world" 5 | import { useTeamApi } from "src/useApi" 6 | 7 | function TrialExpiredAlert() { 8 | return ( 9 | 10 | Trial Expired: Please{" "} 11 | update your subscription to prevent service 12 | disruption. 13 | 14 | ) 15 | } 16 | 17 | function SubscriptionExpiredAlert() { 18 | return ( 19 | 20 | Subscription Expired: Please{" "} 21 | update your subscription to prevent service 22 | disruption. 23 | 24 | ) 25 | } 26 | 27 | function SubscriptionExceededAlert({ 28 | activeUserCount, 29 | licenseCount, 30 | }: { 31 | readonly activeUserCount: number 32 | readonly licenseCount: number 33 | }) { 34 | return ( 35 | 36 | Subscription Exceeded: You have{" "} 37 | {activeUserCount} active users and{" "} 38 | {licenseCount} seat licenses. Please{" "} 39 | upgrade your seat licenses to prevent service 40 | disruption. 41 | 42 | ) 43 | } 44 | 45 | export function SubscriptionAlert() { 46 | const state = useTeamApi(Current.api.getSubscriptionInfo) 47 | 48 | if ( 49 | state.status === "initial" || 50 | state.status === "loading" || 51 | state.status === "failure" 52 | ) { 53 | return null 54 | } 55 | 56 | if (state.data.type === "VALID_SUBSCRIPTION") { 57 | return null 58 | } 59 | 60 | if (state.data.type === "TRIAL_EXPIRED") { 61 | return 62 | } 63 | 64 | if (state.data.type === "SUBSCRIPTION_EXPIRED") { 65 | return 66 | } 67 | 68 | return ( 69 | 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /web_ui/src/components/ToolTip.tsx: -------------------------------------------------------------------------------- 1 | import TippyToolTip from "@tippy.js/react" 2 | import "tippy.js/dist/tippy.css" 3 | export const ToolTip = TippyToolTip 4 | -------------------------------------------------------------------------------- /web_ui/src/components/__snapshots__/LoginPage.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`LoginPage snap smoke test 1`] = ` 4 |
5 |
8 |
12 |
15 | favicon 22 |

25 | Kodiak 26 |

27 |
28 |
29 | 34 |
35 |

38 | 41 | Install 42 | 43 | | 44 | 47 | Docs 48 | 49 | | 50 | 51 | 54 | Help 55 | 56 |

57 |
58 |
59 |
60 | `; 61 | -------------------------------------------------------------------------------- /web_ui/src/custom.scss: -------------------------------------------------------------------------------- 1 | // Helpful site for previewing theme changes: 2 | // https://pikock.github.io/bootstrap-magic/app/index.html#!/editor 3 | 4 | // colors copied from the doc site's siteConfig.js 5 | $purple: #47325f; 6 | $indigo: #b2a0bb; 7 | 8 | $theme-colors: ( 9 | primary: $purple, 10 | secondary: $indigo, 11 | ); 12 | 13 | @import "./node_modules/bootstrap/scss/bootstrap.scss"; 14 | 15 | html, 16 | body, 17 | #root { 18 | height: 100%; 19 | } 20 | 21 | a { 22 | text-decoration: underline; 23 | } 24 | a:hover { 25 | text-decoration: none; 26 | } 27 | 28 | .gh-install-btn { 29 | background-image: linear-gradient(-180deg, #34d058, #28a745 90%); 30 | background-color: #28a745; 31 | background-position: -1px -1px; 32 | background-repeat: repeat-x; 33 | background-size: 110% 110%; 34 | color: #fff; 35 | padding-left: 40px; 36 | padding-right: 40px; 37 | padding-bottom: 14px; 38 | padding-top: 14px; 39 | cursor: pointer; 40 | border: 1px solid rgba(27, 31, 35, 0.2); 41 | border-radius: 0.25rem; 42 | font-weight: 600; 43 | font-size: 18px; 44 | text-decoration: none; 45 | } 46 | .gh-install-btn:hover { 47 | color: #fff; 48 | background-color: #269f42; 49 | background-image: linear-gradient(-180deg, #2fcb53, #269f42 90%); 50 | background-position: -0.5em; 51 | border-color: rgba(27, 31, 35, 0.5); 52 | } 53 | .gh-install-btn:active { 54 | color: #fff; 55 | background-color: #279f43; 56 | background-image: none; 57 | border-color: rgba(27, 31, 35, 0.5); 58 | box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); 59 | } 60 | 61 | .border-hover:hover { 62 | @extend .border; 63 | } 64 | .chart-container { 65 | height: 350px; 66 | } 67 | 68 | .sidebar-overflow-ellipsis { 69 | overflow: hidden; 70 | max-width: 120px; 71 | text-overflow: ellipsis; 72 | } 73 | 74 | .account-chooser-image:hover { 75 | border-color: $dark !important; 76 | } 77 | .account-chooser-image { 78 | border-width: 2px !important; 79 | border-color: lighten($dark, 25%) !important; 80 | } 81 | 82 | .table-sm th { 83 | border-top: 0; 84 | } 85 | 86 | .w-lg-230 { 87 | width: unset; 88 | } 89 | @include media-breakpoint-up(sm) { 90 | .w-lg-230 { 91 | width: 230px; 92 | } 93 | } 94 | 95 | @include media-breakpoint-up(sm) { 96 | .overflow-sm-auto { 97 | overflow: auto; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /web_ui/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import "./custom.scss" 4 | import App from "./components/App" 5 | import * as settings from "./settings" 6 | 7 | import * as Sentry from "@sentry/browser" 8 | 9 | Sentry.init({ dsn: settings.SENTRY_DSN }) 10 | 11 | ReactDOM.render(, document.getElementById("root")) 12 | -------------------------------------------------------------------------------- /web_ui/src/settings.ts: -------------------------------------------------------------------------------- 1 | export const SENTRY_DSN = 2 | "https://0012ad6693d042d1b57ac5f00918b3bd@o64108.ingest.sentry.io/3352104" 3 | export const installUrl = "https://github.com/marketplace/kodiakhq" 4 | export const docsUrl = "https://kodiakhq.com/docs/quickstart" 5 | export const helpUrl = "https://kodiakhq.com/help" 6 | export const billingDocsUrl = "https://kodiakhq.com/docs/billing" 7 | export const loginPath = "/v1/oauth_login" 8 | export const monthlyCost = 499 9 | export const annualCost = 4990 10 | 11 | export const getStripeSelfServeUrl = (teamId: string) => 12 | `/v1/t/${teamId}/stripe_self_serve_redirect` 13 | -------------------------------------------------------------------------------- /web_ui/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import "@testing-library/jest-dom/extend-expect" 6 | -------------------------------------------------------------------------------- /web_ui/src/useApi.ts: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import { useParams } from "react-router-dom" 3 | import { WebData } from "./webdata" 4 | 5 | export function useApi( 6 | func: () => Promise, 7 | ): [WebData, { refetch: () => Promise }] { 8 | const [state, setState] = React.useState>({ 9 | status: "loading", 10 | }) 11 | 12 | const fetch = useCallback(() => { 13 | return func() 14 | .then(res => { 15 | setState({ status: "success", data: res }) 16 | }) 17 | .catch(() => { 18 | setState({ status: "failure" }) 19 | }) 20 | }, [func]) 21 | 22 | React.useEffect(() => { 23 | fetch() 24 | }, [fetch, func]) 25 | 26 | return [state, { refetch: fetch }] 27 | } 28 | 29 | interface ITeamArgs { 30 | readonly teamId: string 31 | } 32 | /** Call API method and return WebData for response 33 | * 34 | * The API function gets called on first load. 35 | */ 36 | export function useTeamApi( 37 | func: (args: ITeamArgs) => Promise, 38 | ): WebData { 39 | const params = useParams<{ team_id: string }>() 40 | const [state, setState] = React.useState>({ 41 | status: "loading", 42 | }) 43 | const teamId = params.team_id 44 | 45 | React.useEffect(() => { 46 | func({ teamId }) 47 | .then(res => { 48 | setState({ status: "success", data: res }) 49 | }) 50 | .catch(() => { 51 | setState({ status: "failure" }) 52 | }) 53 | }, [func, teamId]) 54 | 55 | return state 56 | } 57 | 58 | /** Call API method and return WebData for response 59 | * 60 | * This is similar to useTeamApi but only makes a request when `callApi` is 61 | * called. 62 | */ 63 | export function useTeamApiMutation( 64 | func: (args: V) => Promise, 65 | ): [WebData, (args: Omit) => void] { 66 | const [state, setState] = React.useState>({ 67 | status: "initial", 68 | }) 69 | 70 | function callApi(args: Omit) { 71 | setState({ status: "loading" }) 72 | teamApi(func, args).then(res => { 73 | if (res.ok) { 74 | setState({ status: "success", data: res.data }) 75 | } else { 76 | setState({ status: "failure" }) 77 | } 78 | }) 79 | } 80 | 81 | return [state, callApi] 82 | } 83 | 84 | /** Call API method and insert current teamId from URL. Returns descriminated 85 | * response. */ 86 | export function teamApi( 87 | func: (args: V) => Promise, 88 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 89 | args: Omit = {} as Omit, 90 | ): Promise<{ ok: true; data: T } | { ok: false }> { 91 | const teamId: string = location.pathname.split("/")[2] 92 | // We know better than TS. This is a safe assertion. 93 | // https://github.com/microsoft/TypeScript/issues/35858 94 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 95 | return func({ ...args, teamId } as V) 96 | .then(res => ({ ok: true, data: res })) 97 | .catch(() => ({ ok: false })) 98 | } 99 | -------------------------------------------------------------------------------- /web_ui/src/webdata.ts: -------------------------------------------------------------------------------- 1 | export type WebData = 2 | | { readonly status: "initial" } 3 | | { readonly status: "loading" } 4 | | { readonly status: "refetching"; readonly data: T } 5 | | { readonly status: "success"; readonly data: T } 6 | | { readonly status: "failure" } 7 | -------------------------------------------------------------------------------- /web_ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "build", 4 | "sourceMap": true, 5 | "allowJs": true, 6 | "checkJs": true, 7 | "jsx": "react", 8 | "target": "es2017", 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "allowSyntheticDefaultImports": true, 12 | "strict": true, 13 | "noUnusedLocals": true, 14 | "noUnusedParameters": true, 15 | "removeComments": false, 16 | "preserveConstEnums": true, 17 | "skipLibCheck": true, 18 | "experimentalDecorators": true, 19 | "esModuleInterop": true, 20 | "baseUrl": "." 21 | }, 22 | "include": ["**/*", "*"], 23 | "exclude": ["build", "node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /web_ui/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended", "tslint-config-prettier"], 4 | "jsRules": { 5 | "no-console": false, 6 | "object-literal-sort-keys": false, 7 | "no-shadowed-variable": false, 8 | "member-ordering": false, 9 | "jsdoc-format": false, 10 | "variable-name": [ 11 | true, 12 | "ban-keywords", 13 | "allow-pascal-case", 14 | "check-format", 15 | "allow-leading-underscore" 16 | ] 17 | }, 18 | "rules": { 19 | "no-namespace": true, 20 | "no-string-literal": false, 21 | "ban-types": { 22 | "options": [ 23 | [ 24 | "Function", 25 | "Specify args and return type. This is essentially the any type." 26 | ], 27 | ["Object", "Avoid using the `Object` type. Did you mean `object`?"], 28 | ["Boolean", "Avoid using the `Boolean` type. Did you mean `boolean`?"], 29 | ["Number", "Avoid using the `Number` type. Did you mean `number`?"], 30 | ["String", "Avoid using the `String` type. Did you mean `string`?"], 31 | ["Symbol", "Avoid using the `Symbol` type. Did you mean `symbol`?"] 32 | ] 33 | }, 34 | "jsdoc-format": false, 35 | "object-literal-shorthand": false, 36 | "no-any": true, 37 | "no-unsafe-any": true, 38 | "prefer-object-spread": true, 39 | "ordered-imports": false, 40 | "member-access": false, 41 | "no-console": true, 42 | "no-duplicate-switch-case": true, 43 | "no-function-expression": true, 44 | "no-invalid-template-strings": true, 45 | "no-sparse-arrays": true, 46 | "no-unnecessary-class": true, 47 | "object-literal-sort-keys": false, 48 | "no-shadowed-variable": false, 49 | "member-ordering": false, 50 | "array-type": false, 51 | "no-relative-imports": false, 52 | "no-require-imports": true, 53 | "no-duplicate-imports": true, 54 | "no-unnecessary-local-variable": true, 55 | "max-classes-per-file": [true, 5], 56 | "no-unnecessary-initializer": false, 57 | "variable-name": [ 58 | true, 59 | "ban-keywords", 60 | "allow-pascal-case", 61 | "check-format", 62 | "allow-leading-underscore" 63 | ] 64 | }, 65 | "rulesDirectory": ["node_modules/tslint-microsoft-contrib"] 66 | } 67 | --------------------------------------------------------------------------------